Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
33c5300
add public API to interface
nan-li Mar 27, 2026
faaa927
Add JwtTokenStore, Operation.externalId, ConfigModel.useIdentityVerif…
nan-li Mar 29, 2026
82cb67f
Add jwt parameter to backend service interfaces/impls, add Authorizat…
nan-li Mar 29, 2026
316fedd
Add JWT gating, centralized externalId stamping, and FAIL_UNAUTHORIZE…
nan-li Mar 30, 2026
3c044d4
Update all operation executors to resolve JWT and alias based on iden…
nan-li Mar 30, 2026
aed18a4
Add JWT to In-App Messages backend calls, guard anonymous IAM fetch
nan-li Mar 30, 2026
1d85f7f
Use alias-based IAM fetch endpoint: /users/by/:alias_label/:alias_id/…
nan-li Mar 30, 2026
2120f4f
Wire JWT storage and identity verification guards into login, logout,…
nan-li Mar 30, 2026
f539cb9
Add IdentityVerificationService, register JwtTokenStore in DI
nan-li Mar 30, 2026
59215a3
demo app: add JWT to buttons (login, updateJWT)
nan-li Mar 30, 2026
82b5341
update remote params identity verification key to "jwt_required"
nan-li Mar 30, 2026
b4d2e5d
Fix: set all HTTP headers before writing request body
nan-li Mar 30, 2026
e67ca19
demo app: use Identity verification toggle to make requests
nan-li Mar 30, 2026
7d93fa7
Add isDisabledInternally to SubscriptionModel for IV logout
nan-li Mar 30, 2026
0966ae8
Encapsulate JWT invalidation listener management in UserManager
nan-li Mar 30, 2026
0125f76
debug: dump full operation queue in OperationRepo log
nan-li Mar 30, 2026
436ba14
Fix spotless import ordering in CoreModule and UserManager
nan-li Mar 30, 2026
8c047f9
Fix unit test compilation for identity verification parameters
nan-li Mar 30, 2026
83523a2
Fix demo app stalling on failed login by dismissing loading immediately
nan-li Mar 31, 2026
ef1a424
Fix runtime 401 not notifying developer to provide a new JWT
nan-li Mar 31, 2026
771255f
Propagate externalId to executor result operations in OperationRepo
nan-li Mar 31, 2026
37e1dff
Fix race condition: stamp externalId synchronously before async enqueue
nan-li Mar 31, 2026
9d1ad8f
Harden JwtTokenStore against corrupted SharedPreferences data
nan-li Mar 31, 2026
765114b
Fix null useIdentityVerification blocking all ops for non-IV apps
nan-li Mar 31, 2026
1846e87
Add @Volatile to _jwtInvalidatedHandler for JMM visibility
nan-li Mar 31, 2026
fc594b1
Skip push subscription disable on logout when JWT is already expired
nan-li Mar 31, 2026
3e3086c
Add identity verification manual test plan
nan-li Mar 31, 2026
26de8e5
Update IDENTITY_VERIFICATION_MANUAL_TEST_PLAN.md
nan-li Apr 1, 2026
aff605b
Add KDoc to hasValidJwtIfRequired explain gating
nan-li Apr 2, 2026
d07e5c4
Set externalId at operation creation time instead of stamping in Oper…
nan-li Apr 2, 2026
8b460e7
fireJwtInvalidated off main
nan-li Apr 2, 2026
c57b0fa
nit: update formatting
nan-li Apr 2, 2026
16ce7f6
Add updateUserJwtSuspend and waitForInit to updateUserJwt
nan-li Apr 2, 2026
6844c1f
Address detekt changes
nan-li Apr 2, 2026
2f43ff9
detekt: Baseline 19 ConstructorParameterNaming entries for constructo…
nan-li Apr 2, 2026
cdc61b8
detekt: update 8 LongParameterList entries for IV changes
nan-li Apr 2, 2026
258c8e6
Decouple JWT invalidated delivery and document threading
nan-li Apr 3, 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
34 changes: 30 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?
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)
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 this be in a background thread?

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.

it follows the pattern of other subscribing services like InAppMessagesManager.kt start, and the start() method itself is managed by useBackgroundThreading or not (see StartupService.kt)

_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) }
}

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) {
con.setRequestProperty("Authorization", "Bearer ${headers.jwt}")
}

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)
}

// 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.
* Stamped automatically by [IOperationRepo] at enqueue time from the current identity model
* when not already set by the concrete operation's constructor.
*/
var externalId: String?
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.

how do we indicate this to be JWT, can we do a better naming?

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.

This is the externalId of the user the operation belongs to, I'm not sure I understand your question. While it is relevant for Identity Verification, the term externalId is the most clear

get() = getOptStringProperty(::externalId.name)
set(value) {
setOptStringProperty(::externalId.name, value)
}

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