Skip to content
Open
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
ca6974e
add public API to interface
nan-li Mar 27, 2026
d59aa97
Add JwtTokenStore, Operation.externalId, ConfigModel.useIdentityVerif…
nan-li Mar 29, 2026
08215b2
Add jwt parameter to backend service interfaces/impls, add Authorizat…
nan-li Mar 29, 2026
6c2a7bb
Add JWT gating, centralized externalId stamping, and FAIL_UNAUTHORIZE…
nan-li Mar 30, 2026
cd42bf6
Update all operation executors to resolve JWT and alias based on iden…
nan-li Mar 30, 2026
c51074d
Add JWT to In-App Messages backend calls, guard anonymous IAM fetch
nan-li Mar 30, 2026
c6bc9c0
Use alias-based IAM fetch endpoint: /users/by/:alias_label/:alias_id/…
nan-li Mar 30, 2026
268af36
Wire JWT storage and identity verification guards into login, logout,…
nan-li Mar 30, 2026
c49f26d
Add IdentityVerificationService, register JwtTokenStore in DI
nan-li Mar 30, 2026
18d8575
demo app: add JWT to buttons (login, updateJWT)
nan-li Mar 30, 2026
d87089d
update remote params identity verification key to "jwt_required"
nan-li Mar 30, 2026
1dab8e0
Fix: set all HTTP headers before writing request body
nan-li Mar 30, 2026
50965e0
demo app: use Identity verification toggle to make requests
nan-li Mar 30, 2026
0c8abb8
Add isDisabledInternally to SubscriptionModel for IV logout
nan-li Mar 30, 2026
b145834
Encapsulate JWT invalidation listener management in UserManager
nan-li Mar 30, 2026
0416677
debug: dump full operation queue in OperationRepo log
nan-li Mar 30, 2026
4a9291d
Fix spotless import ordering in CoreModule and UserManager
nan-li Mar 30, 2026
632a358
Fix unit test compilation for identity verification parameters
nan-li Mar 30, 2026
0f1ad82
Fix demo app stalling on failed login by dismissing loading immediately
nan-li Mar 31, 2026
09a4afa
Fix runtime 401 not notifying developer to provide a new JWT
nan-li Mar 31, 2026
4316bd2
Propagate externalId to executor result operations in OperationRepo
nan-li Mar 31, 2026
0585461
Fix race condition: stamp externalId synchronously before async enqueue
nan-li Mar 31, 2026
b0118ed
Harden JwtTokenStore against corrupted SharedPreferences data
nan-li Mar 31, 2026
5686224
Fix null useIdentityVerification blocking all ops for non-IV apps
nan-li Mar 31, 2026
c7bc6b2
Add @Volatile to _jwtInvalidatedHandler for JMM visibility
nan-li Mar 31, 2026
b12c74f
Skip push subscription disable on logout when JWT is already expired
nan-li Mar 31, 2026
e818c0a
Add identity verification manual test plan
nan-li Mar 31, 2026
d663e43
Update IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md
nan-li Apr 1, 2026
ab453bb
Add KDoc to hasValidJwtIfRequired explain gating
nan-li Apr 2, 2026
8cc4812
Set externalId at operation creation time instead of stamping in Oper…
nan-li Apr 2, 2026
e4bf212
fireJwtInvalidated off main
nan-li Apr 2, 2026
374528b
nit: update formatting
nan-li Apr 2, 2026
8606964
Add updateUserJwtSuspend and waitForInit to updateUserJwt
nan-li Apr 2, 2026
89ca431
Address detekt changes
nan-li Apr 2, 2026
12e4794
detekt: Baseline 19 ConstructorParameterNaming entries for constructo…
nan-li Apr 2, 2026
b7b1edb
detekt: update 8 LongParameterList entries for IV changes
nan-li Apr 2, 2026
8e260bd
Decouple JWT invalidated delivery and document threading
nan-li Apr 3, 2026
d198153
fix: login race condition and JWT FAIL_UNAUTHORIZED blocking callers …
abdulraqeeb33 Apr 9, 2026
ff1a6b2
Fix FAIL_UNAUTHORIZED loop when IV off and stuck login when IV arrive…
nan-li Apr 14, 2026
6050a4b
Handle pre-HYDRATE logout edge case and redact JWT from logs
nan-li Apr 14, 2026
6ad204e
Address Claude review bot feedback on IV PR
nan-li Apr 15, 2026
fb2353f
detekt: update baseline instead of addressing
nan-li Apr 15, 2026
13c11ed
Fix pre-HYDRATE logout and IAM fetch TOCTOU race
nan-li Apr 15, 2026
3f3dd09
Suppress anonymous ops at enqueue and add LogoutHelper IV test coverage
nan-li Apr 15, 2026
9d3d46f
Remove manual test plan doc from repo
nan-li Apr 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions OneSignalSDK/detekt/detekt-baseline-core.xml

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,46 @@ interface IOneSignal {
* Logout the current user (suspend version).
*/
suspend fun logoutSuspend()

/**
* Update the JWT bearer token for a user identified by [externalId]. Call this when
* a token is about to expire or after receiving an [IUserJwtInvalidatedListener] callback.
*
* @param externalId The external ID of the user whose token is being updated.
* @param token The new JWT bearer token.
*/
fun updateUserJwt(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

like i mentioned elsewhere, we need suspend methods for these

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added

externalId: String,
token: String,
)

/**
* Update the JWT bearer token for a user identified by [externalId] (suspend version).
* Call this when a token is about to expire or after receiving an [IUserJwtInvalidatedListener]
* callback. This suspend variant waits for the SDK to be initialized before proceeding.
*
* @param externalId The external ID of the user whose token is being updated.
* @param token The new JWT bearer token.
*/
suspend fun updateUserJwtSuspend(
externalId: String,
token: String,
)

/**
* Add a listener that will be called when a user's JWT is invalidated (e.g. expired
* or rejected by the server). Use this to provide a fresh token via [updateUserJwt].
*
* The listener is invoked on a background thread; see [IUserJwtInvalidatedListener].
*
* @param listener The listener to add.
*/
fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener)

/**
* Remove a previously added [IUserJwtInvalidatedListener].
*
* @param listener The listener to remove.
*/
fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.onesignal

/**
* Implement this interface and provide an instance to [OneSignal.addUserJwtInvalidatedListener]
* to be notified when the JWT for a user is invalidated.
*
* Callbacks are delivered on a background thread.
*/
interface IUserJwtInvalidatedListener {
/**
* Called when the JWT is invalidated for [UserJwtInvalidatedEvent.externalId].
* Invoked on a background thread; see [IUserJwtInvalidatedListener] class documentation.
*
* @param event Describes which user's JWT was invalidated.
*/
fun onUserJwtInvalidated(event: UserJwtInvalidatedEvent)
}
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,42 @@ object OneSignal {
@JvmStatic
fun logout() = oneSignal.logout()

/**
* Update the JWT bearer token for a user identified by [externalId]. Call this when
* a token is about to expire or after receiving an [IUserJwtInvalidatedListener] callback.
*
* @param externalId The external ID of the user whose token is being updated.
* @param token The new JWT bearer token.
*/
@JvmStatic
fun updateUserJwt(
externalId: String,
token: String,
) = oneSignal.updateUserJwt(externalId, token)

/**
* Add a listener that will be called when a user's JWT is invalidated (e.g. expired
* or rejected by the server). Use this to provide a fresh token via [updateUserJwt].
*
* The listener is invoked on a background thread; see [IUserJwtInvalidatedListener].
*
* @param listener The listener to add.
*/
@JvmStatic
fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) {
oneSignal.addUserJwtInvalidatedListener(listener)
}

/**
* Remove a previously added [IUserJwtInvalidatedListener].
*
* @param listener The listener to remove.
*/
@JvmStatic
fun removeUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) {
oneSignal.removeUserJwtInvalidatedListener(listener)
}

private val oneSignal: IOneSignal by lazy {
OneSignalImp()
}
Expand Down Expand Up @@ -405,6 +441,22 @@ object OneSignal {
oneSignal.logoutSuspend()
}

/**
* Update the JWT bearer token for a user identified by [externalId] (suspend version).
* Call this when a token is about to expire or after receiving an [IUserJwtInvalidatedListener]
* callback.
*
* @param externalId The external ID of the user whose token is being updated.
* @param token The new JWT bearer token.
*/
@JvmStatic
suspend fun updateUserJwtSuspend(
externalId: String,
token: String,
) {
oneSignal.updateUserJwtSuspend(externalId, token)
}

/**
* Used to retrieve services from the SDK when constructor dependency injection is not an
* option.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.onesignal

/**
* The event passed into [IUserJwtInvalidatedListener.onUserJwtInvalidated]. Delivery occurs on
* a background thread; see [IUserJwtInvalidatedListener].
*/
class UserJwtInvalidatedEvent(
val externalId: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.onesignal.core.internal.background.IBackgroundManager
import com.onesignal.core.internal.background.impl.BackgroundManager
import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.config.impl.ConfigModelStoreListener
import com.onesignal.core.internal.config.impl.IdentityVerificationService
import com.onesignal.core.internal.database.IDatabaseProvider
import com.onesignal.core.internal.database.impl.DatabaseProvider
import com.onesignal.core.internal.device.IDeviceService
Expand Down Expand Up @@ -42,6 +43,7 @@ import com.onesignal.location.ILocationManager
import com.onesignal.location.internal.MisconfiguredLocationManager
import com.onesignal.notifications.INotificationsManager
import com.onesignal.notifications.internal.MisconfiguredNotificationsManager
import com.onesignal.user.internal.identity.JwtTokenStore

internal class CoreModule : IModule {
override fun register(builder: ServiceBuilder) {
Expand All @@ -63,6 +65,10 @@ internal class CoreModule : IModule {
builder.register<ParamsBackendService>().provides<IParamsBackendService>()
builder.register<ConfigModelStoreListener>().provides<IStartableService>()

// Identity Verification
builder.register<JwtTokenStore>().provides<JwtTokenStore>()
builder.register<IdentityVerificationService>().provides<IStartableService>()

// Operations
builder.register<OperationModelStore>().provides<OperationModelStore>()
builder.register<OperationRepo>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,7 @@ internal class ParamsBackendService(
return ParamsObject(
googleProjectNumber = responseJson.safeString("android_sender_id"),
enterprise = responseJson.safeBool("enterp"),
// TODO: New
useIdentityVerification = responseJson.safeBool("require_ident_auth"),
useIdentityVerification = responseJson.safeBool("jwt_required") ?: false,
notificationChannels = responseJson.optJSONArray("chnl_lst"),
firebaseAnalytics = responseJson.safeBool("fba"),
restoreTTLFilter = responseJson.safeBool("restore_ttl_filter"),
Expand All @@ -95,7 +94,6 @@ internal class ParamsBackendService(
unsubscribeWhenNotificationsDisabled = responseJson.safeBool("unsubscribe_on_notifications_disabled"),
locationShared = responseJson.safeBool("location_shared"),
requiresUserPrivacyConsent = responseJson.safeBool("requires_user_privacy_consent"),
// TODO: New
opRepoExecutionInterval = responseJson.safeLong("oprepo_execution_interval"),
features = features,
influenceParams = influenceParams ?: InfluenceParamsObject(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,12 +237,15 @@ class ConfigModel : Model() {
}

/**
* Whether SMS auth hash should be used.
* Whether identity verification (JWT) is required for this application.
* - `null` = unknown (remote params haven't arrived yet; all operations are held)
* - `false` = explicitly disabled (SDK behaves as today, no JWT gating)
* - `true` = enabled (operations require a valid JWT, anonymous users are blocked)
*/
var useIdentityVerification: Boolean
get() = getBooleanProperty(::useIdentityVerification.name) { false }
var useIdentityVerification: Boolean?
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we consider maintaining an Enum state here as opposed to a boolean that signifies three states?

get() = getOptBooleanProperty(::useIdentityVerification.name)
set(value) {
setBooleanProperty(::useIdentityVerification.name, value)
setOptBooleanProperty(::useIdentityVerification.name, value)
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.onesignal.core.internal.config.impl

import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler
import com.onesignal.common.modeling.ModelChangeTags
import com.onesignal.common.modeling.ModelChangedArgs
import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.operations.IOperationRepo
import com.onesignal.core.internal.startup.IStartableService
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.user.internal.UserManager
import com.onesignal.user.internal.identity.IdentityModelStore
import com.onesignal.user.internal.identity.JwtTokenStore

/**
* Reacts to the identity-verification remote param arriving via config HYDRATE.
*
* - When IV transitions from unknown (null) to true: purges anonymous operations.
* - When IV transitions from unknown (null) to any value: wakes the operation queue.
* - On beta migration: if IV=true and the current user has an externalId but no JWT,
* fires [UserJwtInvalidatedEvent] so the developer provides a fresh token.
*/
internal class IdentityVerificationService(
private val _configModelStore: ConfigModelStore,
private val _operationRepo: IOperationRepo,
private val _identityModelStore: IdentityModelStore,
private val _jwtTokenStore: JwtTokenStore,
private val _userManager: UserManager,
) : IStartableService, ISingletonModelStoreChangeHandler<ConfigModel> {
override fun start() {
_configModelStore.subscribe(this)
Comment thread
abdulraqeeb33 marked this conversation as resolved.
_operationRepo.setJwtInvalidatedHandler { externalId ->
_userManager.fireJwtInvalidated(externalId)
}
}

override fun onModelReplaced(
model: ConfigModel,
tag: String,
) {
if (tag != ModelChangeTags.HYDRATE) return

val useIV = model.useIdentityVerification

var jwtInvalidatedExternalId: String? = null
if (useIV == true) {
Logging.debug("IdentityVerificationService: IV enabled, purging anonymous operations")
_operationRepo.removeOperationsWithoutExternalId()

val externalId = _identityModelStore.model.externalId
if (externalId != null && _jwtTokenStore.getJwt(externalId) == null) {
Logging.debug("IdentityVerificationService: IV enabled but no JWT for $externalId, will fire invalidated event after queue wake")
jwtInvalidatedExternalId = externalId
}
}

_operationRepo.forceExecuteOperations()

jwtInvalidatedExternalId?.let { _userManager.fireJwtInvalidated(it) }
Comment thread
nan-li marked this conversation as resolved.
}

override fun onModelUpdated(
args: ModelChangedArgs,
tag: String,
) {
// Individual property updates are not expected for remote params;
// ConfigModelStoreListener replaces the entire model on HYDRATE.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -159,18 +159,6 @@ internal class HttpClient(
con.doOutput = true
}

logHTTPSent(con.requestMethod, con.url, jsonBody, con.requestProperties)

if (jsonBody != null) {
val strJsonBody = JSONUtils.toUnescapedEUIDString(jsonBody)
val sendBytes = strJsonBody.toByteArray(charset("UTF-8"))
con.setFixedLengthStreamingMode(sendBytes.size)
val outputStream = con.outputStream
outputStream.write(sendBytes)
}

// H E A D E R S

if (headers?.cacheKey != null) {
val eTag =
_prefs.getString(
Expand All @@ -195,6 +183,20 @@ internal class HttpClient(
con.setRequestProperty("OneSignal-Session-Duration", headers.sessionDuration.toString())
}

if (headers?.jwt != null) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we try using the kotlin version of null check here?

con.setRequestProperty("Authorization", "Bearer ${headers.jwt}")
}

logHTTPSent(con.requestMethod, con.url, jsonBody, con.requestProperties.filterKeys { it != "Authorization" })

if (jsonBody != null) {
val strJsonBody = JSONUtils.toUnescapedEUIDString(jsonBody)
val sendBytes = strJsonBody.toByteArray(charset("UTF-8"))
con.setFixedLengthStreamingMode(sendBytes.size)
val outputStream = con.outputStream
outputStream.write(sendBytes)
}

// Network request is made from getResponseCode()
httpResponse = con.responseCode

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,9 @@ data class OptionalHeaders(
* Used to track delay between session start and request
*/
val sessionDuration: Long? = null,
/**
* JWT bearer token for identity verification. When non-null, sent as
* `Authorization: Bearer <jwt>` on the request.
*/
val jwt: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ interface IOperationRepo {
suspend fun awaitInitialized()

fun forceExecuteOperations()

/**
* Remove all queued operations that have no externalId (anonymous operations).
* Used by IdentityVerificationService when identity verification is enabled to
* purge operations that cannot be executed without an authenticated user.
*/
fun removeOperationsWithoutExternalId()

/**
* Register a handler to be called when a runtime 401 Unauthorized response
* invalidates a JWT. This allows the caller to notify the developer so they
* can supply a fresh token via [OneSignal.updateUserJwt].
*
* The handler is invoked synchronously on the operation repo thread immediately
* after JWT invalidation and re-queue. It must return quickly; defer heavy work
* to another thread. The SDK default handler only schedules listener delivery.
*/
fun setJwtInvalidatedHandler(handler: ((String) -> Unit)?)
}

// Extension function so the syntax containsInstanceOf<Operation>() can be used over
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ abstract class Operation(name: String) : Model() {
setStringProperty(::name.name, value)
}

/**
* The external ID of the user this operation belongs to. Used by [IOperationRepo] to look up
* the correct JWT when identity verification is enabled, and to gate anonymous operations.
* Must be set by each concrete [Operation] subclass constructor — typically from the current
* identity model's externalId at the time the operation is created.
*/
var externalId: String?
Comment thread
abdulraqeeb33 marked this conversation as resolved.
get() = getOptStringProperty(::externalId.name)
set(value) {
setOptStringProperty(::externalId.name, value)
}
Comment thread
claude[bot] marked this conversation as resolved.

init {
this.name = name
}
Expand Down Expand Up @@ -49,6 +61,13 @@ abstract class Operation(name: String) : Model() {
*/
abstract val canStartExecute: Boolean

/**
* Whether this operation requires a valid JWT when identity verification is enabled.
* Override to return `false` for operations whose backend endpoint does not require
* a JWT (e.g. subscription updates).
*/
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
Expand Down
Loading
Loading