Skip to content
1 change: 1 addition & 0 deletions OneSignalSDK/detekt/detekt-baseline-core.xml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
<ID>ConstructorParameterNaming:InfluenceManager.kt$InfluenceManager$private val _configModelStore: ConfigModelStore</ID>
<ID>ConstructorParameterNaming:InfluenceManager.kt$InfluenceManager$private val _sessionService: ISessionService</ID>
<ID>ConstructorParameterNaming:InstallIdService.kt$InstallIdService$private val _prefs: IPreferencesService</ID>
<ID>ConstructorParameterNaming:JwtTokenStore.kt$JwtTokenStore$private val _prefs: IPreferencesService</ID>
<ID>ConstructorParameterNaming:LanguageContext.kt$LanguageContext$private val _propertiesModelStore: PropertiesModelStore</ID>
<ID>ConstructorParameterNaming:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private val _identityModelStore: IdentityModelStore</ID>
<ID>ConstructorParameterNaming:LoginUserFromSubscriptionOperationExecutor.kt$LoginUserFromSubscriptionOperationExecutor$private val _propertiesModelStore: PropertiesModelStore</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,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.jwt.JwtTokenStore

internal class CoreModule : IModule {
override fun register(builder: ServiceBuilder) {
Expand All @@ -68,6 +69,8 @@ internal class CoreModule : IModule {
builder.register<ConfigModelStoreListener>().provides<IStartableService>()
builder.register<FeatureFlagsRefreshService>().provides<IStartableService>()

builder.register<JwtTokenStore>().provides<JwtTokenStore>()

// Operations
builder.register<OperationModelStore>().provides<OperationModelStore>()
builder.register<OperationRepo>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.onesignal.core.internal.config

import com.onesignal.common.modeling.Model
import com.onesignal.core.internal.http.OneSignalService.ONESIGNAL_API_BASE_URL
import com.onesignal.user.internal.jwt.JwtRequirement
import org.json.JSONArray
import org.json.JSONObject

Expand Down Expand Up @@ -236,13 +237,18 @@ class ConfigModel : Model() {
setBooleanProperty(::enterprise.name, value)
}

/**
* Whether SMS auth hash should be used.
*/
var useIdentityVerification: Boolean
get() = getBooleanProperty(::useIdentityVerification.name) { false }
/** Mirrors backend `jwt_required`. Pre-HYDRATE callers see [JwtRequirement.UNKNOWN]. */
internal var useIdentityVerification: JwtRequirement
get() = JwtRequirement.fromBoolean(getOptBooleanProperty(::useIdentityVerification.name))
set(value) {
setBooleanProperty(::useIdentityVerification.name, value)
setOptBooleanProperty(
::useIdentityVerification.name,
when (value) {
JwtRequirement.UNKNOWN -> null
JwtRequirement.NOT_REQUIRED -> false
JwtRequirement.REQUIRED -> true
},
)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.startup.IStartableService
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.user.internal.jwt.JwtRequirement
import com.onesignal.user.internal.subscriptions.ISubscriptionManager
import kotlinx.coroutines.delay
import java.net.HttpURLConnection
Expand Down Expand Up @@ -85,7 +86,9 @@ internal class ConfigModelStoreListener(

// these are only copied from the backend params when the backend has set them.
params.enterprise?.let { config.enterprise = it }
params.useIdentityVerification?.let { config.useIdentityVerification = it }
params.useIdentityVerification?.let {
config.useIdentityVerification = JwtRequirement.fromBoolean(it)
}
params.firebaseAnalytics?.let { config.firebaseAnalytics = it }
params.restoreTTLFilter?.let { config.restoreTTLFilter = it }
params.clearGroupOnSummaryClick?.let { config.clearGroupOnSummaryClick = it }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,17 @@ internal enum class FeatureFlag(
val activationMode: FeatureActivationMode
) {
// Threading mode is selected once per app startup to avoid mixed-mode behavior mid-session.
//
// Remote key (lowercase) must match backend / Turbine flag id.
//
SDK_BACKGROUND_THREADING(
"sdk_background_threading",
FeatureActivationMode.APP_STARTUP
),

/** JWT signing of SDK requests. IMMEDIATE so a kill-switch doesn't need a cold start. */
SDK_IDENTITY_VERIFICATION(
"sdk_identity_verification",
FeatureActivationMode.IMMEDIATE
),
;

fun isEnabledIn(enabledKeys: Set<String>): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.onesignal.core.internal.backend.impl.FeatureFlagsJsonParser
import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.user.internal.jwt.IdentityVerificationGates
import kotlinx.serialization.json.JsonObject

internal interface IFeatureManager {
Expand Down Expand Up @@ -163,6 +164,13 @@ internal class FeatureManager(
enabled = enabled,
source = "FeatureManager:${feature.activationMode}"
)

FeatureFlag.SDK_IDENTITY_VERIFICATION ->
IdentityVerificationGates.update(
featureFlagOn = enabled,
jwtRequirement = configModelStore.model.useIdentityVerification,
source = "FeatureManager:${feature.activationMode}"
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ object PreferenceOneSignalKeys {
*/
const val PREFS_OS_LOCATION_SHARED = "OS_LOCATION_SHARED"

/** (String) JSON object mapping externalId -> JWT token. */
const val PREFS_OS_JWT_TOKENS = "PREFS_OS_JWT_TOKENS"

// Permissions

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.onesignal.user.internal.jwt

/**
* Wake-up notification from [JwtTokenStore] when the JWT for [externalId] changes.
* Listeners must call [JwtTokenStore.getJwt] for the current value — event delivery
* order is not guaranteed to match mutation order across concurrent writers.
*/
internal interface IJwtUpdateListener {
fun onJwtUpdated(externalId: String)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.onesignal.user.internal.jwt

import com.onesignal.debug.internal.logging.Logging

/**
* Current Identity Verification gate state, pushed by `FeatureManager.applySideEffects`.
*
* Stored state is the tri-state [jwtRequirement] — UNKNOWN until the first HYDRATE so consumers
* that need to distinguish "we don't know yet" from "customer opted out" can. The Boolean
* derivations [ivBehaviorActive] and [newCodePathsRun] cover the common case where consumers
* just want yes/no; UNKNOWN reads as `false` for both, which is the safe default before HYDRATE.
*
* The two gates differ on purpose: [newCodePathsRun] also flips on when our SDK feature flag is
* on, honoring rollout state even when the customer hasn't opted in. [ivBehaviorActive] tracks
* customer config alone.
*
* Invariant `ivBehaviorActive == true ⇒ newCodePathsRun == true` is preserved at every
* observation because both are derived on read from the stored inputs; a reader can't observe an
* inconsistent state.
*/
internal object IdentityVerificationGates {
@Volatile
private var _featureFlagOn: Boolean = false

/** Customer config (`jwt_required`); [JwtRequirement.UNKNOWN] until the first HYDRATE. */
@Volatile
var jwtRequirement: JwtRequirement = JwtRequirement.UNKNOWN
private set

/** Whether IV-specific behavior (JWT attachment, auth error handling) applies. UNKNOWN reads as `false`. */
val ivBehaviorActive: Boolean
get() = jwtRequirement == JwtRequirement.REQUIRED

/** Whether new IV-related code paths should run. `featureFlag_IV_ON || jwt_required == REQUIRED`. */
val newCodePathsRun: Boolean
get() = _featureFlagOn || ivBehaviorActive

/** Idempotent. [source] is logged for traceability. */
fun update(
featureFlagOn: Boolean,
jwtRequirement: JwtRequirement,
source: String,
) {
_featureFlagOn = featureFlagOn
this.jwtRequirement = jwtRequirement

Logging.debug(
"OneSignal: IdentityVerificationGates.update: newCodePathsRun=$newCodePathsRun, " +
"ivBehaviorActive=$ivBehaviorActive (source=$source)",
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.onesignal.user.internal.jwt

/**
* Customer-side JWT requirement, mirrored from the backend `jwt_required` remote param.
* Explicit [UNKNOWN] so callers can distinguish pre-HYDRATE (no value yet) from
* [NOT_REQUIRED] (customer opted out).
*
* Represents only the customer-config side of Identity Verification; do not confuse
* with [com.onesignal.core.internal.features.FeatureFlag.SDK_IDENTITY_VERIFICATION] (our
* SDK-side rollout switch).
*/
internal enum class JwtRequirement {
/** Remote params have not been fetched yet. Treat as non-IV until known. */
UNKNOWN,

/** Customer config `jwt_required=false`. No JWT signing. */
NOT_REQUIRED,

/** Customer config `jwt_required=true`. IV-specific behavior active. */
REQUIRED,
;

companion object {
fun fromBoolean(value: Boolean?): JwtRequirement =
when (value) {
null -> UNKNOWN
false -> NOT_REQUIRED
true -> REQUIRED
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.onesignal.user.internal.jwt

import com.onesignal.common.events.EventProducer
import com.onesignal.common.events.IEventNotifier
import com.onesignal.core.internal.preferences.IPreferencesService
import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys
import com.onesignal.core.internal.preferences.PreferenceStores
import com.onesignal.debug.internal.logging.Logging
import org.json.JSONException
import org.json.JSONObject

/**
* Persistent store mapping externalId -> JWT. Multi-user so ops queued under a previous user
* can still resolve their JWT at execution time. Storage is unconditional; *usage* of JWTs is
* gated on [IdentityVerificationGates.ivBehaviorActive].
*/
internal class JwtTokenStore(
private val _prefs: IPreferencesService,
) : IEventNotifier<IJwtUpdateListener> {
private val tokens: MutableMap<String, String> = mutableMapOf()
private var isLoaded: Boolean = false
private val updates = EventProducer<IJwtUpdateListener>()

override val hasSubscribers: Boolean
get() = updates.hasSubscribers

override fun subscribe(handler: IJwtUpdateListener) = updates.subscribe(handler)

override fun unsubscribe(handler: IJwtUpdateListener) = updates.unsubscribe(handler)

fun getJwt(externalId: String): String? {
synchronized(tokens) {
ensureLoaded()
return tokens[externalId]
}
}

/** Null [jwt] is a no-op; call [invalidateJwt] to remove a token. */
fun putJwt(
externalId: String,
jwt: String?,
) {
if (jwt == null) return
val changed: Boolean
synchronized(tokens) {
ensureLoaded()
changed = tokens[externalId] != jwt
tokens[externalId] = jwt
if (changed) {
persist()
}
}
if (changed) {
updates.fire { it.onJwtUpdated(externalId) }
}
Comment thread
claude[bot] marked this conversation as resolved.
}

/** Removes the JWT for [externalId] and notifies subscribers. */
fun invalidateJwt(externalId: String) {
val existed: Boolean
synchronized(tokens) {
ensureLoaded()
existed = tokens.remove(externalId) != null
if (existed) {
persist()
}
}
if (existed) {
updates.fire { it.onJwtUpdated(externalId) }
}
}

/** Drops JWTs whose externalId isn't in [activeIds]. Call on cold start to bound growth. */
fun pruneToExternalIds(activeIds: Set<String>) {
val removed: Set<String>
synchronized(tokens) {
ensureLoaded()
val toRemove = tokens.keys - activeIds
removed = toRemove.toSet()
if (removed.isNotEmpty()) {
tokens.keys.removeAll(removed)
persist()
}
}
for (externalId in removed) {
updates.fire { it.onJwtUpdated(externalId) }
}
}

/** Caller must hold `synchronized(tokens)`. */
private fun ensureLoaded() {
if (isLoaded) return
val json =
_prefs.getString(
PreferenceStores.ONESIGNAL,
PreferenceOneSignalKeys.PREFS_OS_JWT_TOKENS,
)
if (json != null) {
try {
val obj = JSONObject(json)
for (key in obj.keys()) {
tokens[key] = obj.getString(key)
}
} catch (e: JSONException) {
Logging.warn("JwtTokenStore: failed to parse persisted tokens, starting fresh: ${e.message}")
}
}
Comment thread
nan-li marked this conversation as resolved.
isLoaded = true
}

/** Caller must hold `synchronized(tokens)`. */
private fun persist() {
_prefs.saveString(
PreferenceStores.ONESIGNAL,
PreferenceOneSignalKeys.PREFS_OS_JWT_TOKENS,
JSONObject(tokens.toMap()).toString(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,9 @@ class FeatureFlagTests : FunSpec({
test("SDK_BACKGROUND_THREADING uses the expected remote key") {
FeatureFlag.SDK_BACKGROUND_THREADING.key shouldBe "sdk_background_threading"
}

test("SDK_IDENTITY_VERIFICATION uses the expected remote key and IMMEDIATE activation") {
FeatureFlag.SDK_IDENTITY_VERIFICATION.key shouldBe "sdk_identity_verification"
FeatureFlag.SDK_IDENTITY_VERIFICATION.activationMode shouldBe FeatureActivationMode.IMMEDIATE
}
})
Loading
Loading