Skip to content
Open
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 @@ -179,6 +179,7 @@
3CC063E02B6D7F2A002BB07F /* OneSignalUserMocks.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CC063DF2B6D7F2A002BB07F /* OneSignalUserMocks.h */; settings = {ATTRIBUTES = (Public, ); }; };
3CC063E62B6D7F96002BB07F /* OneSignalUserMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */; };
3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */; };
B91A66287DEA4026A4DC5952 /* OSIdentityModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1399651D1A401EB888DA77 /* OSIdentityModelTests.swift */; };
3CC063EF2B6D7FE8002BB07F /* OneSignalUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE69E19B282ED8060090BB3D /* OneSignalUser.framework */; };
3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */; };
3CC9A6342AFA1FDE008F68FD /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */; };
Expand Down Expand Up @@ -1408,6 +1409,7 @@
3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalUserMocks.swift; sourceTree = "<group>"; };
3CC063EB2B6D7FE8002BB07F /* OneSignalUserTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalUserTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalUserTests.swift; sourceTree = "<group>"; };
6C1399651D1A401EB888DA77 /* OSIdentityModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIdentityModelTests.swift; sourceTree = "<group>"; };
3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConcurrencyTests.swift; sourceTree = "<group>"; };
3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
3CC9A6352AFA26E7008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2384,6 +2386,7 @@
3CDE664A2BFC2A55006DA114 /* OneSignalUserTests-Bridging-Header.h */,
3CF11E3E2C6D61AC002856F5 /* Executors */,
3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */,
6C1399651D1A401EB888DA77 /* OSIdentityModelTests.swift */,
3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */,
3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */,
3C67F7792BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift */,
Expand Down Expand Up @@ -4495,6 +4498,7 @@
DE3568F22C8911EA00AF447C /* IdentityExecutorTests.swift in Sources */,
3C67F77A2BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift in Sources */,
3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */,
B91A66287DEA4026A4DC5952 /* OSIdentityModelTests.swift in Sources */,
3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */,
DE3568F02C89067400AF447C /* SubscriptionsExecutorTests.swift in Sources */,
3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,55 @@ class OSIdentityModel: OSModel {
return internalGetAlias(OS_EXTERNAL_ID)
}

// All access to aliases should go through helper methods with locking
// All access to aliases and jwtBearerToken must go through the lock
var aliases: [String: String] = [:]
private let aliasesLock = NSRecursiveLock()
private let lock = NSRecursiveLock()

// MARK: - JWT

private var jwtBearerTokenLocked: String? // only read/write under self.lock
public var jwtBearerToken: String? {
didSet {
guard jwtBearerToken != oldValue else {
return
get {
lock.withLock { jwtBearerTokenLocked }
}
set {
// Lock only the storage write. The change notifier fires synchronously
// to listeners that may take other locks
let changed = lock.withLock {
guard newValue != jwtBearerTokenLocked else { return false }
jwtBearerTokenLocked = newValue
return true
}
if changed {
self.set(property: OS_JWT_BEARER_TOKEN, newValue: newValue)
}
self.set(property: OS_JWT_BEARER_TOKEN, newValue: jwtBearerToken)
}
}

func isJwtValid() -> Bool {
return jwtBearerToken != nil && jwtBearerToken != "" && jwtBearerToken != OS_JWT_TOKEN_INVALID
/// Returns the bearer token if it is valid, otherwise nil, snapshots once
func getValidJwt() -> String? {
let token = jwtBearerToken
guard let token = token, !token.isEmpty, token != OS_JWT_TOKEN_INVALID else {
return nil
}
return token
}

/**
Atomically transition the JWT token to `OS_JWT_TOKEN_INVALID`. Returns
`true` if the transition occurred, `false` if the token was already invalid.
*/
@discardableResult
func invalidateJwtBearerToken() -> Bool {
let changed = lock.withLock {
guard jwtBearerTokenLocked != OS_JWT_TOKEN_INVALID else { return false }
jwtBearerTokenLocked = OS_JWT_TOKEN_INVALID
return true
}
if changed {
self.set(property: OS_JWT_BEARER_TOKEN, newValue: OS_JWT_TOKEN_INVALID)
}
return changed
}

// MARK: - Initialization
Expand All @@ -66,10 +98,10 @@ class OSIdentityModel: OSModel {
}

override func encode(with coder: NSCoder) {
aliasesLock.withLock {
lock.withLock {
super.encode(with: coder)
coder.encode(aliases, forKey: "aliases")
coder.encode(jwtBearerToken, forKey: OS_JWT_BEARER_TOKEN)
coder.encode(jwtBearerTokenLocked, forKey: OS_JWT_BEARER_TOKEN)
}
}

Expand All @@ -79,20 +111,20 @@ class OSIdentityModel: OSModel {
// log error
return nil
}
self.jwtBearerToken = coder.decodeObject(forKey: OS_JWT_BEARER_TOKEN) as? String
self.jwtBearerTokenLocked = coder.decodeObject(forKey: OS_JWT_BEARER_TOKEN) as? String
self.aliases = aliases
}

/** Threadsafe getter for an alias */
private func internalGetAlias(_ label: String) -> String? {
aliasesLock.withLock {
lock.withLock {
return self.aliases[label]
}
}

/** Threadsafe setter or removal for aliases */
private func internalAddAliases(_ aliases: [String: String]) {
aliasesLock.withLock {
lock.withLock {
for (label, id) in aliases {
// Remove the alias if the ID field is ""
self.aliases[label] = id.isEmpty ? nil : id
Expand All @@ -105,7 +137,7 @@ class OSIdentityModel: OSModel {
Called to clear the model's data in preparation for hydration via a fetch user call.
*/
func clearData() {
aliasesLock.withLock {
lock.withLock {
self.aliases = [:]
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,19 @@ class OSIdentityModelRepo {
This can be optimized in the future to re-use an Identity Model if multiple logins are made for the same user.
*/
func updateJwtToken(externalId: String, token: String) {
var found = false
lock.withLock {
for model in models.values {
if model.externalId == externalId {
model.jwtBearerToken = token
found = true
}
}
// Snapshot matching models under the repo lock, then mutate outside.
// Writing the token fires the model's change notifier synchronously
// (→ onModelUpdated → onJwtTokenChanged); doing that while holding the
// repo lock leaves a trap for future listeners to deadlock on.
let matchingModels: [OSIdentityModel] = lock.withLock {
models.values.filter { $0.externalId == externalId }
}
if !found {
guard !matchingModels.isEmpty else {
OneSignalLog.onesignalLog(ONE_S_LOG_LEVEL.LL_ERROR, message: "Update User JWT called for external ID \(externalId) that does not exist")
return
}
for model in matchingModels {
model.jwtBearerToken = token
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,9 +412,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager {

// JWT is required

if _user.identityModel.isJwtValid(),
let token = _user.identityModel.jwtBearerToken
{
if let token = _user.identityModel.getValidJwt() {
fullHeader["Authorization"] = "Bearer \(token)"
return fullHeader
}
Expand Down Expand Up @@ -716,14 +714,9 @@ extension OneSignalUserManagerImpl {
return
}

// Return, if the token has already been invalidated
guard identityModel.jwtBearerToken != OS_JWT_TOKEN_INVALID else {
return
if identityModel.invalidateJwtBearerToken() {
fireJwtExpired(externalId: externalId)
}

identityModel.jwtBearerToken = OS_JWT_TOKEN_INVALID

fireJwtExpired(externalId: externalId)
}

private func fireJwtExpired(externalId: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,10 @@ internal extension OneSignalRequest {
| --------------- | -------------- | ------- | ------- |
*/
func addJWTHeaderIsValid(identityModel: OSIdentityModel) -> Bool {
let tokenIsValid = identityModel.isJwtValid()
let validToken = identityModel.getValidJwt()
let required = OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired
let canBeSent = (required == false) || (required == true && tokenIsValid)
if canBeSent && tokenIsValid,
let token = identityModel.jwtBearerToken
{
let canBeSent = (required == false) || (required == true && validToken != nil)
if canBeSent, let token = validToken {
// Add the JWT token if it is valid, regardless of requirements
var additionalHeaders = self.additionalHeaders ?? [String: String]()
additionalHeaders["Authorization"] = "Bearer \(token)"
Expand Down
90 changes: 90 additions & 0 deletions iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
Modified MIT License

Copyright 2026 OneSignal

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

1. The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

2. All copies of substantial portions of the Software may only be used in connection
with services provided by OneSignal.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/

import XCTest
import OneSignalCore
@testable import OneSignalOSCore
@testable import OneSignalUser

/// Tests for the two new JWT APIs added to `OSIdentityModel`:
/// - `getValidJwt()` snapshots and returns the bearer token only when it is
/// non-nil, non-empty, and not the `OS_JWT_TOKEN_INVALID` sentinel.
/// - `invalidateJwtBearerToken()` performs an atomic compare-and-set to
/// `OS_JWT_TOKEN_INVALID`, returning `true` only on the transition.
final class OSIdentityModelTests: XCTestCase {

private func makeModel(token: String? = nil) -> OSIdentityModel {
let model = OSIdentityModel(aliases: [:], changeNotifier: OSEventProducer())
model.jwtBearerToken = token
return model
}

// MARK: - getValidJwt()

func testGetValidJwt_returnsNil_whenTokenIsNil() {
XCTAssertNil(makeModel(token: nil).getValidJwt())
}

func testGetValidJwt_returnsNil_whenTokenIsEmptyString() {
XCTAssertNil(makeModel(token: "").getValidJwt())
}

func testGetValidJwt_returnsNil_whenTokenIsInvalidSentinel() {
XCTAssertNil(makeModel(token: OS_JWT_TOKEN_INVALID).getValidJwt())
}

func testGetValidJwt_returnsToken_whenTokenIsValid() {
let token = "eyJhbGciOiJFUzI1NiJ9.payload.sig"
XCTAssertEqual(makeModel(token: token).getValidJwt(), token)
}

// MARK: - invalidateJwtBearerToken()

func testInvalidate_returnsTrueOnFirstTransition_andSetsInvalidSentinel() {
let model = makeModel(token: "valid-token")

XCTAssertTrue(model.invalidateJwtBearerToken())
XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID)
}

func testInvalidate_returnsFalseWhenAlreadyInvalid() {
let model = makeModel(token: "valid-token")
_ = model.invalidateJwtBearerToken()

XCTAssertFalse(model.invalidateJwtBearerToken())
XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID)
}

func testInvalidate_returnsTrueWhenStartingFromNil() {
// Defensive: nil → INVALID is still a real transition, the model lands
// on the sentinel and the caller can fire fireJwtExpired once.
let model = makeModel(token: nil)

XCTAssertTrue(model.invalidateJwtBearerToken())
XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ enum AddItemType {
case tag
case trigger
case externalUserId
case updateUserJwt
case customNotification
case trackEvent

Expand All @@ -90,15 +91,25 @@ enum AddItemType {
case .tag: return "Add Tag"
case .trigger: return "Add Trigger"
case .externalUserId: return "Login User"
case .updateUserJwt: return "Update User JWT"
case .customNotification: return "Custom Notification"
case .trackEvent: return "Track Event"
}
}

var requiresKeyValue: Bool {
switch self {
case .alias, .tag, .trigger, .customNotification: return true
case .email, .sms, .externalUserId, .trackEvent: return false
case .alias, .tag, .trigger, .customNotification, .externalUserId, .updateUserJwt: return true
case .email, .sms, .trackEvent: return false
}
}

/// When true, the second (value) field may be left empty and validation still passes.
/// Used for `.externalUserId` where the JWT token is optional.
var valueIsOptional: Bool {
switch self {
case .externalUserId: return true
default: return false
}
}

Expand All @@ -108,6 +119,7 @@ enum AddItemType {
case .tag: return "Key"
case .trigger: return "Key"
case .customNotification: return "Title"
case .externalUserId, .updateUserJwt: return "External User Id"
default: return "Key"
}
}
Expand All @@ -119,7 +131,8 @@ enum AddItemType {
case .sms: return "SMS"
case .tag: return "Value"
case .trigger: return "Value"
case .externalUserId: return "External User Id"
case .externalUserId: return "JWT Token (Optional)"
case .updateUserJwt: return "JWT Token"
case .customNotification: return "Body"
case .trackEvent: return "Event Name"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,12 @@ final class OneSignalService {

// MARK: - User Management

func login(externalId: String) {
OneSignal.login(externalId)
func login(externalId: String, token: String?) {
OneSignal.login(externalId: externalId, token: token)
}

func updateUserJwt(externalId: String, token: String) {
OneSignal.updateUserJwt(externalId: externalId, token: token)
}

func logout() {
Expand Down
Loading
Loading