diff --git a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/model/MainActivityViewModel.java b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/model/MainActivityViewModel.java index dc84e28a9e..b170f370b7 100644 --- a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/model/MainActivityViewModel.java +++ b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/model/MainActivityViewModel.java @@ -407,11 +407,17 @@ private void setupAppLayout() { revokeConsentButton.setOnClickListener(v -> togglePrivacyConsent(false)); loginUserButton.setOnClickListener(v -> { - dialog.createUpdateAlertDialog("", Dialog.DialogAction.LOGIN, ProfileUtil.FieldType.EXTERNAL_USER_ID, new UpdateAlertDialogCallback() { + dialog.createAddPairAlertDialog("Login User", "External ID", "JWT Token (optional)", ProfileUtil.FieldType.EXTERNAL_USER_ID, new AddPairAlertDialogCallback() { @Override - public void onSuccess(String update) { - if (update != null && !update.isEmpty()) { - OneSignal.login(update); + public void onSuccess(Pair pair) { + String externalId = pair.first; + String jwt = pair.second != null ? pair.second.toString().trim() : ""; + if (externalId != null && !externalId.isEmpty()) { + if (!jwt.isEmpty()) { + OneSignal.login(externalId, jwt); + } else { + OneSignal.login(externalId); + } refreshState(); } } @@ -444,11 +450,13 @@ private void setupJWTLayout() { OneSignal.updateUserJwt(OneSignal.getUser().getExternalId(), ""); }); updateJwtButton.setOnClickListener(v -> { - dialog.createUpdateAlertDialog("", Dialog.DialogAction.UPDATE, ProfileUtil.FieldType.JWT, new UpdateAlertDialogCallback() { + dialog.createAddPairAlertDialog("Update JWT", "External ID", "JWT Token", ProfileUtil.FieldType.EXTERNAL_USER_ID, new AddPairAlertDialogCallback() { @Override - public void onSuccess(String update) { - if (update != null && !update.isEmpty()) { - OneSignal.updateUserJwt(OneSignal.getUser().getExternalId(), update); + public void onSuccess(Pair pair) { + String externalId = pair.first; + String jwt = pair.second != null ? pair.second.toString().trim() : ""; + if (externalId != null && !externalId.isEmpty() && !jwt.isEmpty()) { + OneSignal.updateUserJwt(externalId, jwt); refreshState(); } } diff --git a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/util/Dialog.java b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/util/Dialog.java index 797d06372e..6c25bf857d 100644 --- a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/util/Dialog.java +++ b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/util/Dialog.java @@ -133,6 +133,10 @@ private void toggleUpdateAlertDialogAttributes(boolean disableAttributes) { * Click OK to verify and update the field being updated */ public void createAddPairAlertDialog(String content, final ProfileUtil.FieldType field, final AddPairAlertDialogCallback callback) { + createAddPairAlertDialog(content, "Key", "Value", field, callback); + } + + public void createAddPairAlertDialog(String content, String keyHint, String valueHint, final ProfileUtil.FieldType field, final AddPairAlertDialogCallback callback) { final View addPairAlertDialogView = layoutInflater.inflate(R.layout.add_pair_alert_dialog_layout, null, false); final TextView addPairAlertDialogTitleTextView = addPairAlertDialogView.findViewById(R.id.add_pair_alert_dialog_title_text_view); @@ -142,8 +146,8 @@ public void createAddPairAlertDialog(String content, final ProfileUtil.FieldType final EditText addPairAlertDialogValueEditText = addPairAlertDialogView.findViewById(R.id.add_pair_alert_dialog_value_edit_text); final ProgressBar addPairAlertDialogProgressBar = addPairAlertDialogView.findViewById(R.id.add_pair_alert_dialog_progress_bar); - addPairAlertDialogKeyTextInputLayout.setHint("Key"); - addPairAlertDialogValueTextInputLayout.setHint("Value"); + addPairAlertDialogKeyTextInputLayout.setHint(keyHint); + addPairAlertDialogValueTextInputLayout.setHint(valueHint); addPairAlertDialogTitleTextView.setText(content); font.applyFont(addPairAlertDialogTitleTextView, font.saralaBold); diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt index d2dceea5c3..03364e8771 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt @@ -1,5 +1,6 @@ package com.onesignal.core.internal.operations +import com.onesignal.IUserJwtInvalidatedListener import kotlin.reflect.KClass /** @@ -42,6 +43,19 @@ interface IOperationRepo { suspend fun awaitInitialized() fun forceExecuteOperations() + + /** + * Update the JWT on all queued operations for the given external ID. + * Resets the unauthorized retry counter and wakes the queue processor. + */ + fun updateJwtForExternalId( + externalId: String, + jwt: String, + ) + + fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) + + fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) } // Extension function so the syntax containsInstanceOf() can be used over diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt index 76f51994ab..ba7e6631f7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/Operation.kt @@ -1,6 +1,7 @@ package com.onesignal.core.internal.operations import com.onesignal.common.modeling.Model +import com.onesignal.common.modeling.ModelChangeTags /** * An [Operation] can be enqueued and executed on the [IOperationRepo]. Each concrete-class @@ -49,6 +50,34 @@ abstract class Operation(name: String) : Model() { */ abstract val canStartExecute: Boolean + /** + * The JWT token stamped on this operation at enqueue time. Used by executors to authenticate + * backend requests for the user who created this operation, even if the current user has + * changed since then. + */ + var operationJwt: String? + get() = getOptStringProperty("_jwt") + set(value) { + setOptStringProperty("_jwt", value, ModelChangeTags.NO_PROPOGATE, true) + } + + /** + * The external ID of the user who created this operation, stamped at enqueue time. + * Used to associate operations with specific users for multi-user JWT management. + */ + var operationExternalId: String? + get() = getOptStringProperty("_externalId") + set(value) { + setOptStringProperty("_externalId", value, ModelChangeTags.NO_PROPOGATE, true) + } + + /** + * Whether this operation requires JWT authentication when identity verification is enabled. + * Most operations require JWT; override to return false for operations that don't + * (e.g. UpdateSubscriptionOperation). + */ + open val requiresJwt: Boolean get() = true + /** * Called when an operation has resolved a local ID to a backend ID (i.e. successfully * created a backend resource). Any IDs within the operation that could be local IDs should diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt index c256eb8ad4..b6ea597c38 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt @@ -1,7 +1,13 @@ package com.onesignal.core.internal.operations.impl +import com.onesignal.IUserJwtInvalidatedListener +import com.onesignal.UserJwtInvalidatedEvent +import com.onesignal.common.events.EventProducer +import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler +import com.onesignal.common.modeling.ModelChangedArgs import com.onesignal.common.threading.OSPrimaryCoroutineScope import com.onesignal.common.threading.WaiterWithValue +import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.GroupComparisonType @@ -31,15 +37,20 @@ internal class OperationRepo( private val _identityModelStore: IdentityModelStore, private val _time: ITime, private val _newRecordState: NewRecordsState, -) : IOperationRepo, IStartableService { +) : IOperationRepo, IStartableService, ISingletonModelStoreChangeHandler { + companion object { + private const val FAIL_UNAUTHORIZED_MAX_RETRIES = 3 + } + internal class OperationQueueItem( val operation: Operation, val waiter: WaiterWithValue? = null, val bucket: Int, var retries: Int = 0, + var unauthorizedRetries: Int = 0, ) { override fun toString(): String { - return "bucket:$bucket, retries:$retries, operation:$operation\n" + return "bucket:$bucket, retries:$retries, unauthorizedRetries:$unauthorizedRetries, operation:$operation\n" } } @@ -55,6 +66,7 @@ internal class OperationRepo( private var paused = false private var coroutineScope = CoroutineScope(newSingleThreadContext(name = "OpRepo")) private val initialized = CompletableDeferred() + val jwtInvalidatedCallback = EventProducer() override suspend fun awaitInitialized() { initialized.await() @@ -97,6 +109,7 @@ internal class OperationRepo( } override fun start() { + _configModelStore.subscribe(this) coroutineScope.launch { // load saved operations first then start processing the queue to ensure correct operation order loadSavedOperations() @@ -104,6 +117,20 @@ internal class OperationRepo( } } + override fun onModelReplaced( + model: ConfigModel, + tag: String, + ) { + if (model.isInitializedWithRemote) { + waiter.wake(LoopWaiterMessage(false)) + } + } + + override fun onModelUpdated( + args: ModelChangedArgs, + tag: String, + ) { } + /** * Enqueuing will be performed in a designate coroutine and may not be added instantly. * This is to prevent direct enqueuing from the main thread that may cause a deadlock if loading @@ -147,6 +174,11 @@ internal class OperationRepo( addToStore: Boolean, index: Int? = null, ) { + if (addToStore) { + queueItem.operation.operationJwt = _identityModelStore.model.jwtToken + queueItem.operation.operationExternalId = _identityModelStore.model.externalId + } + synchronized(queue) { val hasExisting = queue.any { it.operation.id == queueItem.operation.id } if (hasExisting) { @@ -262,12 +294,26 @@ internal class OperationRepo( ops.forEach { it.waiter?.wake(true) } } ExecutionResult.FAIL_UNAUTHORIZED -> { - Logging.error("Operation execution failed with invalid jwt") - _identityModelStore.invalidateJwt() + val externalId = ops.first().operation.operationExternalId + Logging.error("Operation execution failed with invalid jwt for externalId: $externalId") - // add back all operations to the front of the queue to be re-executed. synchronized(queue) { - ops.reversed().forEach { queue.add(0, it) } + ops.reversed().forEach { + it.unauthorizedRetries++ + if (it.unauthorizedRetries > FAIL_UNAUTHORIZED_MAX_RETRIES) { + Logging.error("Dropping operation after $FAIL_UNAUTHORIZED_MAX_RETRIES unauthorized retries: ${it.operation}") + _operationModelStore.remove(it.operation.id) + it.waiter?.wake(false) + } else { + it.operation.operationJwt = null + _operationModelStore.persist() + queue.add(0, it) + } + } + } + + if (externalId != null) { + jwtInvalidatedCallback.fire { it.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId)) } } } ExecutionResult.FAIL_NORETRY, @@ -313,9 +359,12 @@ internal class OperationRepo( // if there are operations provided on the result, we need to enqueue them at the // beginning of the queue. if (response.operations != null) { + val startingOp = ops.first().operation synchronized(queue) { for (op in response.operations.reversed()) { op.id = UUID.randomUUID().toString() + op.operationJwt = startingOp.operationJwt + op.operationExternalId = startingOp.operationExternalId val queueItem = OperationQueueItem(op, bucket = 0) queue.add(0, queueItem) _operationModelStore.add(0, queueItem.operation) @@ -374,18 +423,33 @@ internal class OperationRepo( internal fun getNextOps(bucketFilter: Int): List? { return synchronized(queue) { - // Ensure the operation does not have empty JWT if identity verification is on - if (_configModelStore.model.useIdentityVerification && - _identityModelStore.model.jwtToken == null - ) { + Logging.debug("getNextOps queue:\n$queue") + + if (!_configModelStore.model.isInitializedWithRemote) { + Logging.debug("getNextOps: waiting for remote params") return null } + val useIV = _configModelStore.model.useIdentityVerification + if (useIV) { + val toDiscard = + queue.filter { + it.operation.operationExternalId == null && it.operation.requiresJwt + } + for (item in toDiscard) { + Logging.debug("getNextOps: discarding anonymous op: ${item.operation}") + queue.remove(item) + _operationModelStore.remove(item.operation.id) + item.waiter?.wake(false) + } + } + val startingOp = queue.firstOrNull { it.operation.canStartExecute && _newRecordState.canAccess(it.operation.applyToRecordId) && - it.bucket <= bucketFilter + it.bucket <= bucketFilter && + (!useIV || !it.operation.requiresJwt || it.operation.operationJwt != null) } if (startingOp != null) { @@ -443,6 +507,30 @@ internal class OperationRepo( return ops } + override fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + jwtInvalidatedCallback.subscribe(listener) + } + + override fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { + jwtInvalidatedCallback.unsubscribe(listener) + } + + override fun updateJwtForExternalId( + externalId: String, + jwt: String, + ) { + synchronized(queue) { + for (item in queue) { + if (item.operation.operationExternalId == externalId) { + item.operation.operationJwt = jwt + item.unauthorizedRetries = 0 + } + } + } + _operationModelStore.persist() + waiter.wake(LoopWaiterMessage(false)) + } + /** * Load saved operations from preference service and add them into the queue * NOTE: Sometimes the loading might take longer than expected due to I/O reads from disk, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index b8947a9667..92d7b34076 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -451,21 +451,20 @@ internal class OneSignalImp : IOneSignal, IServiceProvider { for (model in identityModelStore!!.store.list()) { if (externalId == model.externalId) { identityModelStore!!.model.jwtToken = token - operationRepo!!.forceExecuteOperations() Logging.log(LogLevel.DEBUG, "JWT $token is updated for externalId $externalId") - return + break } } - Logging.log(LogLevel.DEBUG, "No identity found for externalId $externalId") + operationRepo!!.updateJwtForExternalId(externalId, token) } override fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { - user.addUserJwtInvalidatedListener(listener) + operationRepo!!.addUserJwtInvalidatedListener(listener) } override fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { - user.removeUserJwtInvalidatedListener(listener) + operationRepo!!.removeUserJwtInvalidatedListener(listener) } private fun createAndSwitchToNewUser( diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt index 8183361d49..45f57c7e38 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt @@ -1,7 +1,6 @@ package com.onesignal.user.internal import com.onesignal.IUserJwtInvalidatedListener -import com.onesignal.UserJwtInvalidatedEvent import com.onesignal.common.IDManager import com.onesignal.common.OneSignalUtils import com.onesignal.common.events.EventProducer @@ -43,10 +42,6 @@ internal open class UserManager( val changeHandlersNotifier = EventProducer() - val jwtInvalidatedCallback = EventProducer() - - private var jwtTokenInvalidated: String? = null - override val pushSubscription: IPushSubscription get() = _subscriptionManager.subscriptions.push @@ -251,13 +246,11 @@ internal open class UserManager( } override fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { - Logging.debug("OneSignal.addUserJwtInvalidatedListener(listener: $listener)") - jwtInvalidatedCallback.subscribe(listener) + Logging.debug("UserManager.addUserJwtInvalidatedListener is a no-op; use OneSignal.addUserJwtInvalidatedListener instead") } override fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) { - Logging.debug("OneSignal.removeUserJwtInvalidatedListener(listener: $listener)") - jwtInvalidatedCallback.unsubscribe(listener) + Logging.debug("UserManager.removeUserJwtInvalidatedListener is a no-op; use OneSignal.removeUserJwtInvalidatedListener instead") } override fun onModelReplaced( @@ -276,21 +269,6 @@ internal open class UserManager( it.onUserStateChange(UserChangedState(newUserState)) } } - IdentityConstants.JWT_TOKEN -> { - // Fire the event when the JWT has been invalidated. - val oldJwt = args.oldValue - val newJwt = args.newValue - - // When newJwt is equals to null, we are invalidating JWT for the current user. - // We need to prevent same JWT from being invalidated twice in a row. - if (jwtTokenInvalidated != oldJwt && newJwt == null) { - jwtInvalidatedCallback.fire { - it.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId)) - } - } - - jwtTokenInvalidated = oldJwt as String? - } } } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt index a051001f83..18495e090f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/UpdateSubscriptionOperation.kt @@ -97,6 +97,7 @@ class UpdateSubscriptionOperation() : Operation(SubscriptionOperationExecutor.UP override val groupComparisonType: GroupComparisonType = GroupComparisonType.ALTER override val canStartExecute: Boolean get() = !IDManager.isLocalId(onesignalId) && !IDManager.isLocalId(subscriptionId) override val applyToRecordId: String get() = subscriptionId + override val requiresJwt: Boolean get() = false constructor(appId: String, onesignalId: String, subscriptionId: String, type: SubscriptionType, enabled: Boolean, address: String, status: SubscriptionStatus, jwt: String? = null) : this() { this.appId = appId diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt index a2c59553fc..64e1922ff7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/IdentityOperationExecutor.kt @@ -3,6 +3,7 @@ package com.onesignal.user.internal.operations.impl.executors import com.onesignal.common.NetworkUtils import com.onesignal.common.exceptions.BackendException import com.onesignal.common.modeling.ModelChangeTags +import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.ExecutionResponse import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.IOperationExecutor @@ -19,6 +20,7 @@ import com.onesignal.user.internal.operations.impl.states.NewRecordsState internal class IdentityOperationExecutor( private val _identityBackend: IIdentityBackendService, private val _identityModelStore: IdentityModelStore, + private val _configModelStore: ConfigModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, ) : IOperationExecutor { @@ -45,13 +47,18 @@ internal class IdentityOperationExecutor( if (lastOperation is SetAliasOperation) { try { - val identityAlias = _identityModelStore.getIdentityAlias() + val identityAlias = + if (_configModelStore.model.useIdentityVerification && lastOperation.operationExternalId != null) { + Pair(IdentityConstants.EXTERNAL_ID, lastOperation.operationExternalId!!) + } else { + Pair(IdentityConstants.ONESIGNAL_ID, lastOperation.onesignalId) + } _identityBackend.setAlias( lastOperation.appId, identityAlias.first, identityAlias.second, mapOf(lastOperation.label to lastOperation.value), - _identityModelStore.model.jwtToken, + lastOperation.operationJwt, ) // ensure the now created alias is in the model as long as the user is still current. @@ -96,7 +103,7 @@ internal class IdentityOperationExecutor( IdentityConstants.ONESIGNAL_ID, lastOperation.onesignalId, lastOperation.label, - _identityModelStore.model.jwtToken, + lastOperation.operationJwt, ) // ensure the now deleted alias is not in the model as long as the user is still current. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt index 098aa4e547..5c2161252f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/LoginUserOperationExecutor.kt @@ -182,7 +182,7 @@ internal class LoginUserOperationExecutor( it.second }, properties, - _identityModelStore.model.jwtToken, + createUserOperation.operationJwt, ) val idTranslations = mutableMapOf() // Add the "local-to-backend" ID translation to the IdentifierTranslator for any operations that were diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt index cb987c3a64..3cec8fba27 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt @@ -11,6 +11,7 @@ import com.onesignal.core.internal.operations.Operation import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.user.internal.backend.IUserBackendService +import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.backend.SubscriptionObjectType import com.onesignal.user.internal.builduser.IRebuildUserService import com.onesignal.user.internal.identity.IdentityModel @@ -53,13 +54,18 @@ internal class RefreshUserOperationExecutor( private suspend fun getUser(op: RefreshUserOperation): ExecutionResponse { try { - val identityAlias = _identityModelStore.getIdentityAlias() + val identityAlias = + if (_configModelStore.model.useIdentityVerification && op.operationExternalId != null) { + Pair(IdentityConstants.EXTERNAL_ID, op.operationExternalId!!) + } else { + Pair(IdentityConstants.ONESIGNAL_ID, op.onesignalId) + } val response = _userBackend.getUser( op.appId, identityAlias.first, identityAlias.second, - _identityModelStore.model.jwtToken, + op.operationJwt, ) if (op.onesignalId != _identityModelStore.model.onesignalId) { @@ -70,7 +76,7 @@ internal class RefreshUserOperationExecutor( for (aliasKVP in response.identities) { identityModel[aliasKVP.key] = aliasKVP.value } - identityModel.jwtToken = _identityModelStore.model.jwtToken + identityModel.jwtToken = op.operationJwt val propertiesModel = PropertiesModel() propertiesModel.onesignalId = op.onesignalId diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt index 7cc483a727..4ffb2d852f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/SubscriptionOperationExecutor.kt @@ -107,14 +107,19 @@ internal class SubscriptionOperationExecutor( AndroidUtils.getAppVersion(_applicationService.appContext), ) - val identityAlias = _identityModelStore.getIdentityAlias() + val identityAlias = + if (_configModelStore.model.useIdentityVerification && createOperation.operationExternalId != null) { + Pair(IdentityConstants.EXTERNAL_ID, createOperation.operationExternalId!!) + } else { + Pair(IdentityConstants.ONESIGNAL_ID, createOperation.onesignalId) + } val result = _subscriptionBackend.createSubscription( createOperation.appId, identityAlias.first, identityAlias.second, subscription, - _identityModelStore.model.jwtToken, + createOperation.operationJwt, ) ?: return ExecutionResponse(ExecutionResult.SUCCESS) val backendSubscriptionId = result.first @@ -252,7 +257,7 @@ internal class SubscriptionOperationExecutor( startingOperation.subscriptionId, IdentityConstants.ONESIGNAL_ID, startingOperation.onesignalId, - _identityModelStore.model.jwtToken, + startingOperation.operationJwt, ) } catch (ex: BackendException) { val responseType = NetworkUtils.getResponseStatusType(ex.statusCode) @@ -284,7 +289,7 @@ internal class SubscriptionOperationExecutor( private suspend fun deleteSubscription(op: DeleteSubscriptionOperation): ExecutionResponse { try { - _subscriptionBackend.deleteSubscription(op.appId, op.subscriptionId, _identityModelStore.model.jwtToken) + _subscriptionBackend.deleteSubscription(op.appId, op.subscriptionId, op.operationJwt) // remove the subscription model as a HYDRATE in case for some reason it still exists. _subscriptionModelStore.remove(op.subscriptionId, ModelChangeTags.HYDRATE) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt index aeccd32177..210b44156b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/UpdateUserOperationExecutor.kt @@ -6,6 +6,7 @@ import com.onesignal.common.consistency.enums.IamFetchRywTokenKey import com.onesignal.common.consistency.models.IConsistencyManager import com.onesignal.common.exceptions.BackendException import com.onesignal.common.modeling.ModelChangeTags +import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.ExecutionResponse import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.IOperationExecutor @@ -13,6 +14,7 @@ import com.onesignal.core.internal.operations.Operation import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.user.internal.backend.IUserBackendService +import com.onesignal.user.internal.backend.IdentityConstants import com.onesignal.user.internal.backend.PropertiesDeltasObject import com.onesignal.user.internal.backend.PropertiesObject import com.onesignal.user.internal.backend.PurchaseObject @@ -31,6 +33,7 @@ internal class UpdateUserOperationExecutor( private val _userBackend: IUserBackendService, private val _identityModelStore: IdentityModelStore, private val _propertiesModelStore: PropertiesModelStore, + private val _configModelStore: ConfigModelStore, private val _buildUserService: IRebuildUserService, private val _newRecordState: NewRecordsState, private val _consistencyManager: IConsistencyManager, @@ -147,7 +150,12 @@ internal class UpdateUserOperationExecutor( if (appId != null && onesignalId != null) { try { - val identityAlias = _identityModelStore.getIdentityAlias() + val identityAlias = + if (_configModelStore.model.useIdentityVerification && operations.first().operationExternalId != null) { + Pair(IdentityConstants.EXTERNAL_ID, operations.first().operationExternalId!!) + } else { + Pair(IdentityConstants.ONESIGNAL_ID, onesignalId) + } val rywData = _userBackend.updateUser( appId, @@ -156,7 +164,7 @@ internal class UpdateUserOperationExecutor( propertiesObject, refreshDeviceMetadata, deltasObject, - _identityModelStore.model.jwtToken, + operations.first().operationJwt, ) if (rywData != null) { diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt index 6e9c9a3b26..b92e3c58e5 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt @@ -1,5 +1,7 @@ package com.onesignal.core.internal.operations +import com.onesignal.IUserJwtInvalidatedListener +import com.onesignal.UserJwtInvalidatedEvent import com.onesignal.common.threading.OSPrimaryCoroutineScope import com.onesignal.common.threading.Waiter import com.onesignal.common.threading.WaiterWithValue @@ -39,7 +41,10 @@ import java.util.UUID // Mocks used by every test in this file private class Mocks { - val configModelStore = MockHelper.configModelStore() + val configModelStore = + MockHelper.configModelStore { + it.isInitializedWithRemote = true + } val identityModelStore = MockHelper.identityModelStore { @@ -54,11 +59,13 @@ private class Mocks { every { mockOperationModelStore.loadOperations() } just runs every { mockOperationModelStore.list() } answers { operationStoreList.toList() } every { mockOperationModelStore.add(any()) } answers { operationStoreList.add(firstArg()) } + every { mockOperationModelStore.add(any(), any()) } answers { operationStoreList.add(firstArg(), secondArg()) } every { mockOperationModelStore.remove(any()) } answers { val id = firstArg() val op = operationStoreList.firstOrNull { it.id == id } operationStoreList.remove(op) } + every { mockOperationModelStore.persist() } just runs mockOperationModelStore } @@ -621,6 +628,7 @@ class OperationRepoTests : FunSpec({ // When mocks.operationRepo.start() mocks.operationRepo.enqueue(operation1) + OSPrimaryCoroutineScope.waitForIdle() val job = launch { mocks.operationRepo.enqueueAndWait(operation2) }.also { yield() } mocks.operationRepo.enqueueAndWait(operation3) job.join() @@ -791,52 +799,212 @@ class OperationRepoTests : FunSpec({ opRepo.forceExecuteOperations() } - test("operations that need to be identity verified cannot execute until JWT is provided") { + test("getNextOps skips ops with null JWT when identity verification is on") { val mocks = Mocks() mocks.configModelStore.model.useIdentityVerification = true - val operation = mockOperation() + + val opWithJwt = mockOperation(operationJwt = "valid-jwt", operationExternalId = "userA") + val opWithoutJwt = mockOperation(operationJwt = null, operationExternalId = "userB") + val opRepo = mocks.operationRepo + opRepo.queue.add(OperationQueueItem(opWithoutJwt, bucket = 0)) + opRepo.queue.add(OperationQueueItem(opWithJwt, bucket = 0)) - // When JWT is not supplied - opRepo.start() - opRepo.enqueue(operation) - opRepo.forceExecuteOperations() - val responseBeforeJWT = - withTimeoutOrNull(100) { - opRepo.enqueueAndWait(mockOperation()) - } + // When + val result = opRepo.getNextOps(0) + + // Then - skips opWithoutJwt, returns opWithJwt + result shouldNotBe null + result!!.size shouldBe 1 + result[0].operation shouldBe opWithJwt + } + + test("FAIL_UNAUTHORIZED nulls JWT on operations and fires listener with correct externalId") { + // Given + val mocks = Mocks() + val opRepo = mocks.operationRepo + + val listenerEvents = mutableListOf() + opRepo.addUserJwtInvalidatedListener( + object : IUserJwtInvalidatedListener { + override fun onUserJwtInvalidated(event: UserJwtInvalidatedEvent) { + listenerEvents.add(event.externalId) + } + }, + ) + + coEvery { + mocks.executor.execute(any()) + } returns ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED) + + val operation = mockOperation(operationJwt = "test-jwt", operationExternalId = "userA") + val queueItem = OperationQueueItem(operation, bucket = 0) + + // When - call executeOperations directly to test the handler in isolation + opRepo.executeOperations(listOf(queueItem)) + + // Then + verify { operation.operationJwt = null } + listenerEvents.size shouldBe 1 + listenerEvents[0] shouldBe "userA" + opRepo.queue.size shouldBe 1 + } + test("getNextOps allows requiresJwt=false ops through even with null JWT") { + val mocks = Mocks() + mocks.configModelStore.model.useIdentityVerification = true + + val op = mockOperation(operationJwt = null, operationExternalId = null, requiresJwt = false) + mocks.operationRepo.queue.add(OperationQueueItem(op, bucket = 0)) + + // When + val result = mocks.operationRepo.getNextOps(0) + + // Then + result shouldNotBe null + result!!.size shouldBe 1 + result[0].operation shouldBe op + } + + test("getNextOps discards anonymous ops when identity verification is on") { + val mocks = Mocks() + mocks.configModelStore.model.useIdentityVerification = true + + val anonOp = mockOperation(operationExternalId = null, requiresJwt = true) + val anonOpId = anonOp.id + mocks.operationRepo.queue.add(OperationQueueItem(anonOp, bucket = 0)) - // Then response should be null - responseBeforeJWT shouldBe null + // When + val result = mocks.operationRepo.getNextOps(0) - // When JWT is updated - mocks.identityModelStore.model.jwtToken = "123" - val opToExecute = opRepo.getNextOps(0) + // Then - anonymous op discarded, queue is empty + result shouldBe null + mocks.operationRepo.queue.size shouldBe 0 + verify { mocks.operationModelStore.remove(anonOpId) } + } - // Operation is ready to execute - opToExecute shouldNotBe null + test("getNextOps does not discard or skip anything when identity verification is off") { + val mocks = Mocks() + mocks.configModelStore.model.useIdentityVerification = false + + val op = mockOperation(operationJwt = null, operationExternalId = null, requiresJwt = true) + mocks.operationRepo.queue.add(OperationQueueItem(op, bucket = 0)) + + // When + val result = mocks.operationRepo.getNextOps(0) + + // Then + result shouldNotBe null + result!!.size shouldBe 1 } - test("JWT will be invalidated when a FAIL_UNAUTHORIZED response is returned") { + test("getNextOps returns null when isInitializedWithRemote is false") { + val mocks = Mocks() + mocks.configModelStore.model.isInitializedWithRemote = false + + val op = mockOperation(operationJwt = "jwt", operationExternalId = "user") + mocks.operationRepo.queue.add(OperationQueueItem(op, bucket = 0)) + + // When + val result = mocks.operationRepo.getNextOps(0) + + // Then + result shouldBe null + mocks.operationRepo.queue.size shouldBe 1 + } + + test("FAIL_UNAUTHORIZED drops ops after max retries") { // Given val mocks = Mocks() val opRepo = mocks.operationRepo - val identityModelStore = mocks.identityModelStore - coEvery { opRepo.delayBeforeNextExecution(any(), any()) } just runs + coEvery { mocks.executor.execute(any()) - } returns ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED) andThen ExecutionResponse(ExecutionResult.SUCCESS) + } returns ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED) + + val op = mockOperation(operationJwt = "jwt", operationExternalId = "user") + val opId = op.id + + // Simulate 3 prior unauthorized retries (next one should drop) + val queueItem = OperationQueueItem(op, bucket = 0, unauthorizedRetries = 3) + + // When - call executeOperations directly (getNextOps would have removed from queue) + opRepo.executeOperations(listOf(queueItem)) + + // Then - op should be dropped, not re-added to queue + opRepo.queue.size shouldBe 0 + verify { mocks.operationModelStore.remove(opId) } + } + + test("updateJwtForExternalId updates JWT and resets retry count on matching ops") { + // Given + val mocks = Mocks() + val opRepo = mocks.operationRepo + + val op = mockOperation(operationJwt = null, operationExternalId = "userA") + val item = OperationQueueItem(op, bucket = 0, unauthorizedRetries = 2) + opRepo.queue.add(item) + + // When + opRepo.updateJwtForExternalId("userA", "new-jwt") + + // Then + verify { op.operationJwt = "new-jwt" } + item.unauthorizedRetries shouldBe 0 + verify { mocks.operationModelStore.persist() } + } + + test("updateJwtForExternalId does not affect ops for different users") { + // Given + val mocks = Mocks() + val opRepo = mocks.operationRepo + + val opA = mockOperation(operationJwt = null, operationExternalId = "userA") + val opB = mockOperation(operationJwt = null, operationExternalId = "userB") + opRepo.queue.add(OperationQueueItem(opA, bucket = 0)) + opRepo.queue.add(OperationQueueItem(opB, bucket = 0)) + + // When + opRepo.updateJwtForExternalId("userA", "new-jwt-a") + + // Then + verify { opA.operationJwt = "new-jwt-a" } + verify(exactly = 0) { opB.operationJwt = any() } + } + + test("enqueue stamps JWT and externalId from IdentityModelStore") { + // Given + val mocks = Mocks() + mocks.identityModelStore.model.jwtToken = "current-jwt" + mocks.identityModelStore.model.externalId = "currentUser" + val operation = mockOperation() + // When + mocks.operationRepo.enqueue(operation) + OSPrimaryCoroutineScope.waitForIdle() + + // Then + verify { operation.operationJwt = "current-jwt" } + verify { operation.operationExternalId = "currentUser" } + } + + test("follow-up operations inherit JWT and externalId from starting operation") { + // Given + val mocks = Mocks() + val followUpOp = mockOperation() + val startingOp = mockOperation(operationJwt = "start-jwt", operationExternalId = "startUser") + + coEvery { + mocks.executor.execute(listOf(startingOp)) + } returns ExecutionResponse(ExecutionResult.SUCCESS, operations = listOf(followUpOp)) + // When mocks.operationRepo.start() - mocks.operationRepo.enqueueAndWait(operation) + mocks.operationRepo.executeOperations(listOf(OperationQueueItem(startingOp, bucket = 0))) // Then - coVerifyOrder { - mocks.executor.execute(listOf(operation)) - identityModelStore.invalidateJwt() - } + verify { followUpOp.operationJwt = "start-jwt" } + verify { followUpOp.operationExternalId = "startUser" } } }) { companion object { @@ -849,6 +1017,9 @@ class OperationRepoTests : FunSpec({ modifyComparisonKey: String = "modify-key", operationIdSlot: CapturingSlot? = null, applyToRecordId: String = "", + operationJwt: String? = null, + operationExternalId: String? = null, + requiresJwt: Boolean = true, ): Operation { val operation = mockk() val opIdSlot = operationIdSlot ?: slot() @@ -862,6 +1033,11 @@ class OperationRepoTests : FunSpec({ every { operation.modifyComparisonKey } returns modifyComparisonKey every { operation.translateIds(any()) } just runs every { operation.applyToRecordId } returns applyToRecordId + every { operation.operationJwt } returns operationJwt + every { operation.operationJwt = any() } just runs + every { operation.operationExternalId } returns operationExternalId + every { operation.operationExternalId = any() } just runs + every { operation.requiresJwt } returns requiresJwt return operation } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt index d2ae66bf5d..3e9f6622cc 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/IdentityOperationExecutorTests.kt @@ -41,7 +41,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -77,7 +77,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -98,7 +98,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -119,7 +119,7 @@ class IdentityOperationExecutorTests : FunSpec({ every { mockBuildUserService.getRebuildOperationsIfCurrentUser(any(), any()) } returns null val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -142,7 +142,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockConfigModelStore = MockHelper.configModelStore().also { it.model.opRepoPostCreateRetryUpTo = 1_000 } val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add("onesignalId") } val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, newRecordState) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockConfigModelStore, mockBuildUserService, newRecordState) val operations = listOf(SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1")) // When @@ -169,7 +169,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -192,7 +192,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -212,7 +212,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -234,7 +234,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockBuildUserService = mockk() val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, getNewRecordState()) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -259,7 +259,7 @@ class IdentityOperationExecutorTests : FunSpec({ val mockConfigModelStore = MockHelper.configModelStore().also { it.model.opRepoPostCreateRetryUpTo = 1_000 } val newRecordState = getNewRecordState(mockConfigModelStore).also { it.add("onesignalId") } val identityOperationExecutor = - IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockBuildUserService, newRecordState) + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockConfigModelStore, mockBuildUserService, newRecordState) val operations = listOf(DeleteAliasOperation("appId", "onesignalId", "aliasKey1")) // When @@ -268,4 +268,79 @@ class IdentityOperationExecutorTests : FunSpec({ // Then response.result shouldBe ExecutionResult.FAIL_RETRY } + + test("set alias uses operation's external_id when IV is enabled, not current user") { + // Given + val mockIdentityBackendService = mockk() + coEvery { mockIdentityBackendService.setAlias(any(), any(), any(), any(), any()) } returns mapOf() + + val mockIdentityModel = mockk() + every { mockIdentityModel.onesignalId } returns "currentUserOnesignalId" + + val mockIdentityModelStore = mockk() + every { mockIdentityModelStore.model } returns mockIdentityModel + + val mockBuildUserService = mockk() + val mockConfigModelStore = MockHelper.configModelStore { it.useIdentityVerification = true } + + val identityOperationExecutor = + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, mockConfigModelStore, mockBuildUserService, getNewRecordState()) + + val operation = SetAliasOperation("appId", "previousUserOnesignalId", "aliasKey1", "aliasValue1") + operation.operationJwt = "previousUserJwt" + operation.operationExternalId = "previousUserExternalId" + val operations = listOf(operation) + + // When + val response = identityOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockIdentityBackendService.setAlias( + "appId", + IdentityConstants.EXTERNAL_ID, + "previousUserExternalId", + mapOf("aliasKey1" to "aliasValue1"), + "previousUserJwt", + ) + } + } + + test("set alias uses onesignal_id when IV is disabled") { + // Given + val mockIdentityBackendService = mockk() + coEvery { mockIdentityBackendService.setAlias(any(), any(), any(), any(), any()) } returns mapOf() + + val mockIdentityModel = mockk() + every { mockIdentityModel.onesignalId } returns "onesignalId" + every { mockIdentityModel.setStringProperty(any(), any(), any()) } just runs + every { mockIdentityModel.jwtToken } returns null + + val mockIdentityModelStore = mockk() + every { mockIdentityModelStore.model } returns mockIdentityModel + + val mockBuildUserService = mockk() + + val identityOperationExecutor = + IdentityOperationExecutor(mockIdentityBackendService, mockIdentityModelStore, MockHelper.configModelStore(), mockBuildUserService, getNewRecordState()) + + val operation = SetAliasOperation("appId", "onesignalId", "aliasKey1", "aliasValue1") + val operations = listOf(operation) + + // When + val response = identityOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockIdentityBackendService.setAlias( + "appId", + IdentityConstants.ONESIGNAL_ID, + "onesignalId", + mapOf("aliasKey1" to "aliasValue1"), + null, + ) + } + } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt index 9dd1c03bfc..916f67dd17 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/RefreshUserOperationExecutorTests.kt @@ -329,4 +329,103 @@ class RefreshUserOperationExecutorTests : FunSpec({ mockUserBackendService.getUser(appId, IdentityConstants.ONESIGNAL_ID, remoteOneSignalId) } } + + test("refresh user uses operation's external_id when IV is enabled, not current user") { + // Given + val previousUserExternalId = "previousUser" + val previousUserJwt = "previousUserJwt" + + val mockUserBackendService = mockk() + coEvery { + mockUserBackendService.getUser(appId, IdentityConstants.EXTERNAL_ID, previousUserExternalId, previousUserJwt) + } returns + CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId, IdentityConstants.EXTERNAL_ID to previousUserExternalId), + PropertiesObject(), + listOf(), + null, + ) + + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockIdentityModel = IdentityModel() + mockIdentityModel.onesignalId = "currentUserOnesignalId" + every { mockIdentityModelStore.model } returns mockIdentityModel + + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockSubscriptionsModelStore = mockk() + val mockBuildUserService = mockk() + + val refreshUserOperationExecutor = + RefreshUserOperationExecutor( + mockUserBackendService, + mockIdentityModelStore, + mockPropertiesModelStore, + mockSubscriptionsModelStore, + MockHelper.configModelStore { it.useIdentityVerification = true }, + mockBuildUserService, + getNewRecordState(), + ) + + val op = RefreshUserOperation(appId, remoteOneSignalId) + op.operationJwt = previousUserJwt + op.operationExternalId = previousUserExternalId + val operations = listOf(op) + + // When + val response = refreshUserOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockUserBackendService.getUser(appId, IdentityConstants.EXTERNAL_ID, previousUserExternalId, previousUserJwt) + } + } + + test("refresh user uses onesignal_id when IV is disabled") { + // Given + val mockUserBackendService = mockk() + coEvery { + mockUserBackendService.getUser(appId, IdentityConstants.ONESIGNAL_ID, remoteOneSignalId, null) + } returns + CreateUserResponse( + mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId), + PropertiesObject(), + listOf(), + null, + ) + + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockIdentityModel = IdentityModel() + mockIdentityModel.onesignalId = remoteOneSignalId + every { mockIdentityModelStore.model } returns mockIdentityModel + every { mockIdentityModelStore.replace(any(), any()) } just runs + + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + every { mockPropertiesModelStore.replace(any(), any()) } just runs + val mockSubscriptionsModelStore = mockk() + every { mockSubscriptionsModelStore.replaceAll(any(), any()) } just runs + val mockBuildUserService = mockk() + + val refreshUserOperationExecutor = + RefreshUserOperationExecutor( + mockUserBackendService, + mockIdentityModelStore, + mockPropertiesModelStore, + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + ) + + val operations = listOf(RefreshUserOperation(appId, remoteOneSignalId)) + + // When + val response = refreshUserOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockUserBackendService.getUser(appId, IdentityConstants.ONESIGNAL_ID, remoteOneSignalId, null) + } + } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt index b4202a56e4..157f78063b 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/SubscriptionOperationExecutorTests.kt @@ -798,4 +798,119 @@ class SubscriptionOperationExecutorTests : mockConsistencyManager.setRywData(remoteOneSignalId, IamFetchRywTokenKey.SUBSCRIPTION, rywData) } } + + test("create subscription uses operation's external_id when IV is enabled, not current user") { + // Given + val previousUserExternalId = "previousUserExternalId" + val previousUserJwt = "previousUserJwt" + + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.createSubscription(any(), any(), any(), any(), any()) } returns + Pair(remoteSubscriptionId, rywData) + + val mockSubscriptionsModelStore = mockk() + val subscriptionModel1 = SubscriptionModel() + subscriptionModel1.id = localSubscriptionId + every { mockSubscriptionsModelStore.get(localSubscriptionId) } returns subscriptionModel1 + + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockBuildUserService = mockk() + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockIdentityModelStore, + mockSubscriptionsModelStore, + MockHelper.configModelStore { it.useIdentityVerification = true }, + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val createOp = + CreateSubscriptionOperation( + appId, + remoteOneSignalId, + localSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken1", + SubscriptionStatus.SUBSCRIBED, + ) + createOp.operationJwt = previousUserJwt + createOp.operationExternalId = previousUserExternalId + val operations = listOf(createOp) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockSubscriptionBackendService.createSubscription( + appId, + IdentityConstants.EXTERNAL_ID, + previousUserExternalId, + any(), + previousUserJwt, + ) + } + } + + test("create subscription uses onesignal_id when IV is disabled") { + // Given + val mockSubscriptionBackendService = mockk() + coEvery { mockSubscriptionBackendService.createSubscription(any(), any(), any(), any(), any()) } returns + Pair(remoteSubscriptionId, rywData) + + val mockSubscriptionsModelStore = mockk() + val subscriptionModel1 = SubscriptionModel() + subscriptionModel1.id = localSubscriptionId + every { mockSubscriptionsModelStore.get(localSubscriptionId) } returns subscriptionModel1 + + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockBuildUserService = mockk() + + val subscriptionOperationExecutor = + SubscriptionOperationExecutor( + mockSubscriptionBackendService, + MockHelper.deviceService(), + AndroidMockHelper.applicationService(), + mockIdentityModelStore, + mockSubscriptionsModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val createOp = + CreateSubscriptionOperation( + appId, + remoteOneSignalId, + localSubscriptionId, + SubscriptionType.PUSH, + true, + "pushToken1", + SubscriptionStatus.SUBSCRIBED, + ) + val operations = listOf(createOp) + + // When + val response = subscriptionOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockSubscriptionBackendService.createSubscription( + appId, + IdentityConstants.ONESIGNAL_ID, + remoteOneSignalId, + any(), + null, + ) + } + } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt index de2e148ff8..699b9fd20d 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/operations/UpdateUserOperationExecutorTests.kt @@ -53,6 +53,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), mockConsistencyManager, @@ -93,6 +94,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), mockConsistencyManager, @@ -155,6 +157,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), mockConsistencyManager, @@ -200,6 +203,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), mockConsistencyManager, @@ -265,6 +269,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), mockConsistencyManager, @@ -313,6 +318,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), mockConsistencyManager, @@ -346,6 +352,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + mockConfigModelStore, mockBuildUserService, newRecordState, mockConsistencyManager, @@ -376,6 +383,7 @@ class UpdateUserOperationExecutorTests : mockUserBackendService, mockIdentityModelStore, mockPropertiesModelStore, + MockHelper.configModelStore(), mockBuildUserService, getNewRecordState(), mockConsistencyManager, @@ -394,4 +402,89 @@ class UpdateUserOperationExecutorTests : mockConsistencyManager.setRywData(remoteOneSignalId, IamFetchRywTokenKey.USER, rywData) } } + + test("update user uses operation's external_id when IV is enabled, not current user") { + // Given + val previousUserExternalId = "previousUserExternalId" + val previousUserJwt = "previousUserJwt" + + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any(), any()) } returns rywData + + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockBuildUserService = mockk() + + val updateUserOperationExecutor = + UpdateUserOperationExecutor( + mockUserBackendService, + mockIdentityModelStore, + mockPropertiesModelStore, + MockHelper.configModelStore { it.useIdentityVerification = true }, + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val op = SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1") + op.operationJwt = previousUserJwt + op.operationExternalId = previousUserExternalId + val operations = listOf(op) + + // When + val response = updateUserOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockUserBackendService.updateUser( + appId, + IdentityConstants.EXTERNAL_ID, + previousUserExternalId, + any(), + any(), + any(), + previousUserJwt, + ) + } + } + + test("update user uses onesignal_id when IV is disabled") { + // Given + val mockUserBackendService = mockk() + coEvery { mockUserBackendService.updateUser(any(), any(), any(), any(), any(), any()) } returns rywData + + val mockIdentityModelStore = MockHelper.identityModelStore() + val mockPropertiesModelStore = MockHelper.propertiesModelStore() + val mockBuildUserService = mockk() + + val updateUserOperationExecutor = + UpdateUserOperationExecutor( + mockUserBackendService, + mockIdentityModelStore, + mockPropertiesModelStore, + MockHelper.configModelStore(), + mockBuildUserService, + getNewRecordState(), + mockConsistencyManager, + ) + + val operations = listOf(SetTagOperation(appId, remoteOneSignalId, "tagKey1", "tagValue1")) + + // When + val response = updateUserOperationExecutor.execute(operations) + + // Then + response.result shouldBe ExecutionResult.SUCCESS + coVerify(exactly = 1) { + mockUserBackendService.updateUser( + appId, + IdentityConstants.ONESIGNAL_ID, + remoteOneSignalId, + any(), + any(), + any(), + ) + } + } }) diff --git a/docs/identity-verification-overview.md b/docs/identity-verification-overview.md new file mode 100644 index 0000000000..83ab696aac --- /dev/null +++ b/docs/identity-verification-overview.md @@ -0,0 +1,142 @@ +# Identity Verification in the OneSignal Android SDK + +## What is Identity Verification? + +Identity verification prevents impersonation by requiring a server-generated JWT (JSON Web Token) to accompany API requests that act on a user. When enabled, the SDK attaches a `Bearer` token to every outgoing HTTP request, and the OneSignal backend rejects any request that carries an invalid or missing token. + +The feature is **controlled server-side**: the OneSignal dashboard sets a flag (`jwt_required`) that the SDK fetches at startup via the remote params endpoint. The SDK stores this in `ConfigModel.useIdentityVerification`. + +--- + +## End-to-End Flow + +``` +┌──────────┐ login(externalId, jwt) ┌──────────────┐ +│ App │ ─────────────────────────► │ SDK │ +│ Code │ │ │ +│ │ ◄── onUserJwtInvalidated ──│ OperationRepo│ +│ │ │ │ +│ │ updateUserJwt(id, jwt) │ │ +│ │ ─────────────────────────► │ │ +└──────────┘ └──────┬───────┘ + │ + Authorization: Bearer + │ + ▼ + ┌──────────────┐ + │ OneSignal │ + │ Backend │ + └──────────────┘ +``` + +### Step by step + +1. **App fetches remote params** — The SDK calls `android_params.js`. The response contains `"jwt_required": true/false`, which sets `ConfigModel.useIdentityVerification`. + +2. **App logs in with a JWT** — The app calls `OneSignal.login(externalId, jwtBearerToken)`. The JWT is stored in `IdentityModel.jwtToken`. + +3. **Operations are stamped** — When any operation is enqueued into `OperationRepo`, the current JWT and external ID are copied onto the operation (`operationJwt`, `operationExternalId`). This means each operation remembers *which user* created it, even if the active user changes later. + +4. **Queue filtering** — Before executing, `OperationRepo.getNextOps()` applies two filters when identity verification is on: + - **Discards anonymous operations**: operations with no `operationExternalId` that require JWT are dropped. + - **Blocks JWT-less operations**: operations that require JWT but have `operationJwt == null` are skipped (they sit in the queue waiting for a token). + +5. **HTTP request** — The executor passes the JWT to the backend service, which sets the `Authorization: Bearer ` header via `HttpClient`. + +6. **Alias selection** — When identity verification is enabled and the operation has an external ID, executors identify the user by `external_id` instead of `onesignal_id`: + ```kotlin + if (_configModelStore.model.useIdentityVerification && operationExternalId != null) { + Pair(IdentityConstants.EXTERNAL_ID, operationExternalId!!) + } else { + Pair(IdentityConstants.ONESIGNAL_ID, onesignalId) + } + ``` + +--- + +## What Happens on a 401/403 (Unauthorized) + +When the backend returns **401 or 403**, the executor reports `FAIL_UNAUTHORIZED`. The `OperationRepo` then: + +1. **Clears the JWT** on the failed operations (`operationJwt = null`) and re-queues them at the front. +2. **Increments a retry counter** (`unauthorizedRetries`) on each operation. +3. **Fires `IUserJwtInvalidatedListener`** so the app can fetch a fresh token from its own backend. +4. If an operation exceeds **3 unauthorized retries**, it is **dropped permanently**. + +The operations sit in the queue with a null JWT, effectively paused, until the app provides a new token. + +### Recovery + +When the app calls `OneSignal.updateUserJwt(externalId, newToken)`: + +- Every queued operation matching that `externalId` gets the new JWT. +- The retry counter resets to 0. +- The operation queue is woken up to resume processing. + +--- + +## Startup Behavior (`IdentityVerificationService`) + +On app cold start, the `IdentityVerificationService` listens for the config model to hydrate from cached remote params. Once hydrated: + +- If identity verification is **on** and no JWT is cached, it logs a message and **does not enqueue** the login operation. The SDK waits for the app to call `login(externalId, jwt)`. +- If identity verification is **off**, or a JWT **is** cached, it enqueues a `LoginUserOperation` automatically to re-establish the session. + +--- + +## Public API + +### Login with JWT + +```kotlin +OneSignal.login("user_123", jwtToken) +``` + +Both the external ID and JWT are required when identity verification is enabled. Calling `login` without a JWT when verification is on will log a warning and block the login. + +### Update an Expired JWT + +```kotlin +OneSignal.updateUserJwt("user_123", freshJwtToken) +``` + +Call this after receiving the invalidation callback (or proactively when you know the token is about to expire). + +### Listen for JWT Invalidation + +```kotlin +OneSignal.addUserJwtInvalidatedListener { event -> + val externalId = event.externalId + // Fetch a new JWT from your server, then: + OneSignal.updateUserJwt(externalId, newJwt) +} +``` + +### Operations That Skip JWT + +Most operations require JWT when identity verification is on. The exception is `UpdateSubscriptionOperation`, which overrides `requiresJwt` to `false` — subscription updates (e.g. push token refresh) can proceed without a valid JWT. + +--- + +## Key Classes + +| Class | Responsibility | +|---|---| +| `ConfigModel` | Stores `useIdentityVerification` (from `jwt_required` remote param) | +| `IdentityModel` | Stores the current user's `externalId` and `jwtToken` | +| `Operation` | Base class; carries `operationJwt`, `operationExternalId`, and `requiresJwt` | +| `OperationRepo` | Stamps JWT on enqueue, filters queue, handles 401 retry logic, fires invalidation callbacks | +| `IdentityVerificationService` | Bootstrap service that gates the initial login operation on JWT availability | +| `HttpClient` | Attaches `Authorization: Bearer` header when JWT is present | +| `IUserJwtInvalidatedListener` | Callback interface the app implements to be notified of expired tokens | + +--- + +## Summary + +- Identity verification is a **server-controlled** feature toggled by `jwt_required` in remote params. +- The JWT is **stamped per-operation** at enqueue time, tying each operation to a specific user. +- On **401/403**, operations are paused and the app is notified via `IUserJwtInvalidatedListener`. +- The app recovers by calling `updateUserJwt`, which re-arms all queued operations for that user. +- After **3 failed retries**, an operation is permanently dropped. +- `UpdateSubscriptionOperation` is exempt from JWT requirements so push token updates are never blocked.