Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@
7AFE856C2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; };
7AFE856D2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; };
7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */; };
9345FA2E8878EA1B0216A49A /* UserJwtAskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */; };
8D2F4893453206700BB60F85 /* OSOperationRepoTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5221EEDBA5A74BD565490D52 /* OSOperationRepoTestSupport.swift */; };
8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */; };
911E2CBD1E398AB3003112A4 /* UnitTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 911E2CBC1E398AB3003112A4 /* UnitTests.m */; };
Expand Down Expand Up @@ -1595,6 +1596,7 @@
5BC1DE632C90BB9000CA8807 /* OSIamFetchReadyCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyCondition.swift; sourceTree = "<group>"; };
5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSConsistencyManagerTests.swift; sourceTree = "<group>"; };
5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtLifecycleTests.swift; sourceTree = "<group>"; };
3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtAskTests.swift; sourceTree = "<group>"; };
6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfig.swift; sourceTree = "<group>"; };
67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IamFetchIdentityVerificationTests.swift; sourceTree = "<group>"; };
6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtRepo.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2533,6 +2535,7 @@
047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */,
3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */,
5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */,
3567F486AA4335CCA79D659B /* UserJwtAskTests.swift */,
);
path = OneSignalUserTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -4722,6 +4725,7 @@
AAFA2D46E6C5FD3D14D39F27 /* OSRequestAuthTests.swift in Sources */,
23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */,
7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */,
9345FA2E8878EA1B0216A49A /* UserJwtAskTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,19 @@ public final class OSUserJwtConfig: NSObject {
@objc
public func hydrate(requiresUserAuth: Bool) {
let hydrated: OSRequiresUserAuth = requiresUserAuth ? .on : .off
// Keep the log and the handler out of the lock; either can re-enter and read the requirement.
// Only the value changes under the lock. The write flushes to disk, and every reader of
// `requirement` (the repo queue, IAM on main, the executors) would wait on it; the log and the
// handler can re-enter and read the requirement.
let (previous, handler) = lock.withLock { () -> (OSRequiresUserAuth, ((OSRequiresUserAuth) -> Void)?) in
let previous = _requirement
_requirement = hydrated
// Written even when the value is unchanged, so a launch whose write was dropped by locked
// storage still ends up with the requirement on disk.
OneSignalUserDefaults.initShared().saveInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, withValue: hydrated.rawValue)
return (previous, onHydrated)
}
// Written even when the value is unchanged, so a launch whose write was dropped by locked
// storage still ends up with the requirement on disk. Two hydrates racing with different values
// could leave disk holding the older one while memory holds the newer; `hydrate` has one guarded
// call site per session, and the next session's params fetch heals a stale cache.
OneSignalUserDefaults.initShared().saveInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, withValue: hydrated.rawValue)
if previous != hydrated {
OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserJwtConfig requirement changed from \(previous) to \(hydrated)")
}
Expand All @@ -100,15 +104,27 @@ public final class OSUserJwtConfig: NSObject {

/**
Re-reads the cached requirement while it is still unknown. The read in `init` can land during an
app prewarm, before first unlock, when UserDefaults silently returns nothing.
app prewarm, before first unlock, when UserDefaults silently returns nothing. Fires the hydrated
handler when the value moves off `unknown`, the same transition `hydrate` reports, so a caller
registered before this runs is told the same way.
*/
public func refreshIfUnknown() {
lock.withLock {
let (refreshed, handler) = lock.withLock { () -> (OSRequiresUserAuth?, ((OSRequiresUserAuth) -> Void)?) in
guard _requirement == .unknown else {
return
return (nil, nil)
}
_requirement = OSUserJwtConfig.cachedRequirement()
let cached = OSUserJwtConfig.cachedRequirement()
guard cached != .unknown else {
return (nil, nil)
}
_requirement = cached
return (cached, onHydrated)
}
guard let refreshed else {
return
}
OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserJwtConfig requirement refreshed from the cache to \(refreshed)")
handler?(refreshed)
}

/// The Identity Verification service is the sole observer, so a second registration replaces the first.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,29 @@ final class OSUserJwtConfigTests: XCTestCase {
XCTAssertEqual(jwtConfig.requirement, .on)
}

/// The same transition `hydrate` reports, so a handler registered before the refresh hears it too.
func testRefreshFiresTheHydratedHandlerWhenItAdoptsACachedRequirement() {
let jwtConfig = OSUserJwtConfig()
var reported: [OSRequiresUserAuth] = []
jwtConfig.setOnHydratedHandler { reported.append($0) }
cacheRequirement(.on)

jwtConfig.refreshIfUnknown()

XCTAssertEqual(reported, [.on])
}

func testRefreshFiresNothingWhileTheCacheIsStillEmpty() {
let jwtConfig = OSUserJwtConfig()
var reported: [OSRequiresUserAuth] = []
jwtConfig.setOnHydratedHandler { reported.append($0) }

jwtConfig.refreshIfUnknown()

XCTAssertEqual(jwtConfig.requirement, .unknown)
XCTAssertTrue(reported.isEmpty)
}

func testRefreshLeavesAKnownRequirementAlone() {
let jwtConfig = OSUserJwtConfig()
jwtConfig.hydrate(requiresUserAuth: false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ class OSUserExecutor {
`login` that promoted an anonymous user while the requirement was still unknown — becomes the Create
User that login would have made, or is dropped if a later `login` has superseded it.

Runs on every send because `refreshIfUnknown` can raise `requirement` with no event; reads the live
model because this executor sends nothing while `requirement` is unknown.
Runs on every send rather than only when the requirement hydrates, and reads the live model: this
executor sends nothing while `requirement` is unknown, so the queue is always judged against a known
value, and the check is cheap.
*/
private func reshapeInvalidRequests() {
guard identityVerificationService.ivBehaviorActive else {
Expand Down
5 changes: 3 additions & 2 deletions iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,9 @@ final class OSRequestAuth: OSRequestAuthorizing {

/**
Nothing else prompts the app when a Request merely parks: the invalidated event fires on a rejected
token, and a token the app never supplied — or supplied in a session that has since ended — leaves the
SDK holding none with nothing to reject. The repo keeps this to one ask per external ID per session.
token, and a token the app never supplied, or supplied in a session that has since ended, leaves the
SDK holding none with nothing to reject. The repo keeps this to one ask per external ID until a token
is stored or a `login` as that user rearms it.
*/
private func park(_ request: OSUserRequest, ownedBy externalId: String) {
// Log only on the ask that reaches the app; later prepareForExecution retries stay quiet.
Expand Down
16 changes: 13 additions & 3 deletions iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ protocol OSUserJwtProviding: AnyObject {
/**
Asks the app for a token for `externalId`.

Returns `true` if this call is the one that asked, which happens at most once per external ID
per session so a burst of concurrent callers does not fire the event repeatedly.
Returns `true` if this call is the one that asked. An external ID is asked once, until a usable token
is stored for it or a `login` builds it a new Identity Model, so a burst of concurrent callers does
not fire the event repeatedly.
*/
@discardableResult
func askForToken(externalId: String) -> Bool
Expand Down Expand Up @@ -104,11 +105,20 @@ final class OSUserJwtRepo: OSUserJwtProviding {
return true
}

/// External IDs already asked this session; cleared when a usable token is stored.
/// External IDs asked and not yet answered. A stored token or a `login` as that user clears the entry.
func pendingTokenAsks() -> [String] {
return lock.withLock { Array(askedForToken) }
}

/**
Forgets an ask so a later park can ask again. A `login` that builds a new Identity Model for this
external ID is a fresh chance to hear about the token it owes; without this, an ask left unanswered
before a logout silenced every later token-less login as the same user for the rest of the session.
*/
func clearAsk(externalId: String) {
lock.withLock { _ = askedForToken.remove(externalId) }
}

@discardableResult
func invalidateJwt(externalId: String, rejectedToken: String) -> Bool {
// No model for this user means the token could not have come from here. A Request stamped
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,44 @@
import OneSignalCore
import OneSignalOSCore

/**
The app's JWT listeners, and whether one was ever added. An ask nobody hears is replayed when a listener
is added, so before the first registration it is the cold-start order and logs at debug; after one, the
app has let its listener go (they are held weakly) and only the warning tells it.
*/
final class OSUserJwtInvalidatedListeners {
private let observer = OSObservable<OSUserJwtInvalidatedListener, OSUserJwtInvalidatedEvent>(
change: #selector(OSUserJwtInvalidatedListener.onUserJwtInvalidated(event:))
)
private let lock = NSLock()
private var everRegistered = false

func add(_ listener: OSUserJwtInvalidatedListener) {
// Observer first, so an ask that lands between the two is heard rather than logged as a lost listener.
observer.addObserver(listener)
lock.withLock { everRegistered = true }
}

func remove(_ listener: OSUserJwtInvalidatedListener) {
observer.removeObserver(listener)
}

func notify(externalId: String) {
// Read first: a listener added during delivery is told by the replay, not by this warning.
let hadListener = lock.withLock { everRegistered }
if observer.notifyChange(OSUserJwtInvalidatedEvent(externalId: externalId)) {
return
}
let unheard = "asked for a JWT for externalId \(externalId)"
let replayed = "the ask is replayed when a listener is added"
if hadListener {
OneSignalLog.onesignalLog(.LL_WARN, message: "\(unheard) but the OSUserJwtInvalidatedListener registered earlier is gone; \(replayed)")
} else {
OneSignalLog.onesignalLog(.LL_DEBUG, message: "\(unheard) before any OSUserJwtInvalidatedListener was registered; \(replayed)")
}
}
}

/**
The Identity Verification surface the app talks to: it hands the SDK a token for a user, and the SDK
tells it when that token stopped being accepted.
Expand All @@ -51,28 +89,40 @@ extension OneSignalUserManagerImpl {
notification — the work that travels through neither.

Every app-supplied token arrives here, from `login` as well as `updateUserJwt`, so that the pending
ask for this user is cleared and a later rejection can ask again.
ask for this user is cleared and a later rejection can ask again. Returns whether the token was
stored; the repo refuses an unusable one.
*/
func storeJwt(externalId: String, token: String) {
@discardableResult
func storeJwt(externalId: String, token: String) -> Bool {
guard userJwtRepo.updateJwt(externalId: externalId, token: token) else {
return
return false
}
OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager stored a JWT for externalId: \(externalId)")
guard identityVerificationService.newCodePathsRun else {
return
return true
}
operationRepo.addFlushDeltaQueueToDispatchQueue()
userExecutor?.executePendingRequests()
NotificationCenter.default.post(name: Notification.Name(OS_ON_USER_JWT_UPDATED), object: nil)
return true
}

/// `storeJwt` clears the ask itself when it stores; a login with no usable token rearms it instead.
func storeJwtOrRearmAsk(externalId: String, token: String?) {
if let token = token, storeJwt(externalId: externalId, token: token) {
return
}
userJwtRepo.clearAsk(externalId: externalId)
}

/**
Replays any ask that already fired this session, so a listener registered after `start` or `login`
still hears who currently owes a token.
still hears who currently owes a token. The listener is held weakly, so the app has to keep its own
reference to it.
*/
@objc
public func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) {
self.userJwtInvalidatedObserver.addObserver(listener)
self.userJwtInvalidatedListeners.add(listener)
let pending = userJwtRepo.pendingTokenAsks()
guard !pending.isEmpty else {
return
Expand All @@ -91,7 +141,7 @@ extension OneSignalUserManagerImpl {

@objc
public func removeUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) {
self.userJwtInvalidatedObserver.removeObserver(listener)
self.userJwtInvalidatedListeners.remove(listener)
}

@objc
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,17 +180,10 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager {
return userStateChangesObserver
}

// JWT Invalidated Observer
private var _userJwtInvalidatedObserver: OSObservable<OSUserJwtInvalidatedListener, OSUserJwtInvalidatedEvent>?
var userJwtInvalidatedObserver: OSObservable<OSUserJwtInvalidatedListener, OSUserJwtInvalidatedEvent> {
if let observer = _userJwtInvalidatedObserver {
return observer
}
let userJwtInvalidatedObserver = OSObservable<OSUserJwtInvalidatedListener, OSUserJwtInvalidatedEvent>(change: #selector(OSUserJwtInvalidatedListener.onUserJwtInvalidated(event:)))
_userJwtInvalidatedObserver = userJwtInvalidatedObserver

return userJwtInvalidatedObserver
}
// The app's JWT listeners. Built in `init` rather than lazily: the ask path reaches them from the
// executor queues while the app registers from its own thread, and a lazy getter with no lock could
// build two and discard the one holding the app's listener.
let userJwtInvalidatedListeners: OSUserJwtInvalidatedListeners

// Model Stores
let identityModelStore = OSModelStore<OSIdentityModel>(changeSubscription: OSEventProducer(), storeKey: OS_IDENTITY_MODEL_STORE_KEY).registerAsUserObserver()
Expand Down Expand Up @@ -219,12 +212,13 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager {
private override init() {
let identityVerificationService = OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig)
let operationRepo = OSOperationRepo(identityVerificationService: identityVerificationService)
// Goes through `sharedInstance` rather than capturing self: the observer it notifies is created
// lazily and must not be touched during init.
let userJwtInvalidatedListeners = OSUserJwtInvalidatedListeners()
// Captures the listeners, not self, so the closure is safe to build before init completes.
let userJwtRepo = OSUserJwtRepo(identityModelRepo: identityModelRepo) { externalId in
OneSignalUserManagerImpl.sharedInstance.userJwtInvalidatedObserver.notifyChange(OSUserJwtInvalidatedEvent(externalId: externalId))
userJwtInvalidatedListeners.notify(externalId: externalId)
}
self.identityVerificationService = identityVerificationService
self.userJwtInvalidatedListeners = userJwtInvalidatedListeners
self.userJwtRepo = userJwtRepo
self.requestAuth = OSRequestAuth(identityVerificationService: identityVerificationService, jwt: userJwtRepo)
self.operationRepo = operationRepo
Expand Down Expand Up @@ -447,8 +441,8 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager {
}

let newUser = setNewInternalUser(externalId: externalId, pushSubscriptionModel: pushSubscriptionModel)
if let externalId = externalId, let token = token {
storeJwt(externalId: externalId, token: token)
if let externalId = externalId {
storeJwtOrRearmAsk(externalId: externalId, token: token)
}
userExecutor!.createUser(newUser)
return newUser
Expand All @@ -475,9 +469,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager {
let newUser = setNewInternalUser(externalId: externalId, pushSubscriptionModel: pushSubscriptionModel)
// The token belongs on the model that carries `external_id`: the Fetch User this leads to is signed
// with it, as is the Create User this becomes if the requirement turns out to be on.
if let token = token {
storeJwt(externalId: externalId, token: token)
}
storeJwtOrRearmAsk(externalId: externalId, token: token)

// Now proceed to identify the previous user
userExecutor!.identifyUser(
Expand Down
12 changes: 12 additions & 0 deletions iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ final class OSUserJwtRepoTests: XCTestCase {
XCTAssertEqual(asked, ["user-a"])
}

/// A login that builds a new Identity Model for the user is a fresh chance to be asked.
func testClearingAnAskLetsTheUserBeAskedAgain() {
XCTAssertTrue(repo.askForToken(externalId: "user-a"))
XCTAssertFalse(repo.askForToken(externalId: "user-a"))

repo.clearAsk(externalId: "user-a")

XCTAssertTrue(repo.askForToken(externalId: "user-a"))
XCTAssertEqual(asked, ["user-a", "user-a"])
XCTAssertEqual(repo.pendingTokenAsks(), ["user-a"])
}

// MARK: - invalidateJwt

func testInvalidatingParksTheTokenAndAsksTheApp() {
Expand Down
Loading
Loading