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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 6.7.0

- Add federated JWT authentication support. Use `enableAuth()` on the config builder to supply a token provider. The SDK attaches `X-User-JWT` to all user-identified requests (OptiTrack, RealTime, PreferenceCenter, EmbeddedMessaging, AnalyticsHelper, InAppManager).
- Add `X-Optimove-Auth-Capable: 1` header to all requests to signal auth-capable SDK versions to backends.
- Fix multi-customer event batching: OptiTrack/RealTime now group events by customer identity so each request carries a single valid JWT. AnalyticsHelper fetches events per user to ensure JWT matches the batch.

## 6.6.0

- Implementation for Overlay Messaging channel. Check optimove developer docs for more.
Expand Down
6 changes: 6 additions & 0 deletions OptimobileShared/OptimobileHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ enum OptimobileHelper {
return OptimobileHelper.installId
}

static var associatedUserId: String? {
userIdLock.wait()
defer { userIdLock.signal() }
return KeyValPersistenceHelper.object(forKey: OptimobileUserDefaultsKey.USER_ID.rawValue) as? String
}

static func getBadgeFromUserInfo(userInfo: [AnyHashable: Any]) -> NSNumber? {
let custom = userInfo["custom"] as? [AnyHashable: Any]
let aps = userInfo["aps"] as? [AnyHashable: Any]
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

96 changes: 96 additions & 0 deletions OptimoveCore/Sources/Classes/Auth/AuthManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright © 2026 Optimove. All rights reserved.

import Foundation

/// Closure type that the client app provides to supply JWTs for authenticated requests.
/// - Parameters:
/// - userId: The user identifier to fetch a token for.
/// - completion: Call with `(token, nil)` on success or `(nil, error)` on failure.
public typealias AuthTokenProvider = (
_ userId: String,
_ completion: @escaping (_ token: String?, _ error: Error?) -> Void
) -> Void

/// Errors related to auth token fetching.
public enum AuthError: Error, LocalizedError {
case tokenFetchFailed
case tokenFetchTimedOut
case noUserId

public var errorDescription: String? {
switch self {
case .tokenFetchFailed:
return "Failed to fetch auth token from provider."
case .tokenFetchTimedOut:
return "Timed out fetching auth token from provider."
case .noUserId:
return "No userId available for auth token request."
}
}
}

/// Manages JWT token retrieval from the client-provided closure.
/// - Threading: `getToken` can be called from any queue. The `completion` callback is invoked
/// on the queue the client's `AuthTokenProvider` closure dispatches to, or on a background queue
/// if the provider times out — callers should not assume any specific queue and should dispatch
/// to their target queue if needed.
public final class AuthManager {
private let provider: AuthTokenProvider
private let tokenFetchTimeout: TimeInterval

public init(
tokenFetchTimeout: TimeInterval = 10,
provider: @escaping AuthTokenProvider
) {
self.provider = provider
self.tokenFetchTimeout = tokenFetchTimeout
}

/// Request a JWT for the given userId from the client-provided closure.
/// - Parameters:
/// - userId: The user identifier to authenticate.
/// - completion: Called with `.success(token)` or `.failure(error)`.
/// Invoked on the queue chosen by the client's `AuthTokenProvider`, or on a background queue
/// if the provider times out — no specific queue is guaranteed.
public func getToken(userId: String, completion: @escaping (Result<String, Error>) -> Void) {
let lock = NSLock()
var didComplete = false
var timeoutWorkItem: DispatchWorkItem?

func completeOnce(_ result: Result<String, Error>) {
lock.lock()
guard !didComplete else {
lock.unlock()
return
}
didComplete = true
let workItem = timeoutWorkItem
timeoutWorkItem = nil
lock.unlock()
workItem?.cancel()
completion(result)
}

if tokenFetchTimeout > 0 {
let workItem = DispatchWorkItem {
completeOnce(.failure(AuthError.tokenFetchTimedOut))
}
lock.lock()
timeoutWorkItem = workItem
lock.unlock()
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + tokenFetchTimeout, execute: workItem)
}

provider(userId) { token, error in
if let token = token {
completeOnce(.success(token))
} else {
completeOnce(.failure(error ?? AuthError.tokenFetchFailed))
}
}
}
}

#if swift(>=5.5)
extension AuthManager: @unchecked Sendable {}
#endif
2 changes: 1 addition & 1 deletion OptimoveCore/Sources/Classes/Constants/SDKVersion.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Copyright © 2019 Optimove. All rights reserved.

public let SDKVersion = "6.6.0"
public let SDKVersion = "6.7.0"
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ extension NetworkClientImpl: NetworkClient {

request.headers?.forEach { urlRequest.addValue($0.value, forHTTPHeaderField: $0.field) }

// Signal that this SDK version supports auth.
urlRequest.addValue("1", forHTTPHeaderField: "X-Optimove-Auth-Capable")
urlRequest.addValue("ios", forHTTPHeaderField: "X-Optimove-Platform")

let task = session.dataTask(with: urlRequest) { data, response, error in
if let error = error {
completion(.failure(NetworkError.error(error)))
Expand All @@ -65,7 +69,9 @@ extension NetworkClientImpl: NetworkClient {
switch httpResponse.statusCode {
case 200 ... 299:
completion(.success(NetworkResponse<Data?>(statusCode: httpResponse.statusCode, body: data)))
case 400 ... 499:
case 401:
completion(.failure(NetworkError.unauthorized(data)))
case 400, 402 ... 499:
completion(.failure(NetworkError.requestInvalid(data)))
case 500 ... 599:
completion(.failure(NetworkError.requestFailed))
Expand Down
14 changes: 14 additions & 0 deletions OptimoveCore/Sources/Classes/NetworkClient/NetworkError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ public enum NetworkError: LocalizedError {
case error(Error)
case noData
case invalidURL
case authFailed(Error)
case unauthorized(Data?)
/// Backend returned 401 but no AuthManager is configured — will never produce a JWT.
case authNotConfigured
case requestInvalid(Data?)
case requestFailed

Expand All @@ -18,6 +22,16 @@ public enum NetworkError: LocalizedError {
return "No data returns."
case .invalidURL:
return "Invalid URL."
case let .authFailed(error):
return "Auth token fetch failed: \(error.localizedDescription)"
case let .unauthorized(data):
var msg = "Unauthorized (401)."
if let data = data, let string = String(bytes: data, encoding: .utf8) {
msg = msg + "\n\(string)"
}
return msg
case .authNotConfigured:
return "Backend requires authentication but enableAuth() was not configured."
case let .requestInvalid(data):
var msg = "Invalid resquest."
if let data = data, let string = String(bytes: data, encoding: .utf8) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ public extension HTTPHeader {
case contentType = "Content-Type"
case userAgent = "User-Agent"
case tenantId = "X-Tenant-Id"
case userJwt = "X-User-JWT"
case accept
}

Expand All @@ -114,6 +115,11 @@ public extension HTTPHeader {
self.field = field.rawValue
self.value = String(describing: value)
}

init(field: Fields, value: String) {
self.field = field.rawValue
self.value = value
}
}

extension HTTPHeader: CustomStringConvertible {
Expand Down
132 changes: 132 additions & 0 deletions OptimoveCore/Sources/Classes/Optistream/OptistreamDispatcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright © 2026 Optimove. All rights reserved.

import Foundation

/// Orchestrates event dispatch: groups events by customer, resolves JWTs via AuthManager,
/// and delegates each group to the underlying `OptistreamNetworking` transport.
///
/// Callers receive per-group results via `onGroupResult`, allowing them to handle partial
/// success (e.g. remove successfully sent events from a persistent queue while keeping
/// failed ones for retry).
public protocol OptistreamDispatcher {
/// Send a batch of events, potentially splitting them by customer identity for auth.
///
/// - Parameters:
/// - events: The batch of events to send.
/// - path: Optional path appended to the endpoint URL.
/// - onGroupResult: Called once per customer group with that group's send result.
/// Groups are processed sequentially; the next group starts only after the previous
/// one's `onGroupResult` fires.
/// - completion: Called once after all groups have been processed.
func sendBatch(
events: [OptistreamEvent],
path: String?,
onGroupResult: @escaping (_ groupEvents: [OptistreamEvent], _ result: Result<Void, NetworkError>) -> Void,
completion: @escaping () -> Void
)
}

public final class OptistreamDispatcherImpl: OptistreamDispatcher {
private let networking: OptistreamNetworking
private let authManager: AuthManager?

public init(
networking: OptistreamNetworking,
authManager: AuthManager? = nil
) {
self.networking = networking
self.authManager = authManager
}

public func sendBatch(
events: [OptistreamEvent],
path: String?,
onGroupResult: @escaping (_ groupEvents: [OptistreamEvent], _ result: Result<Void, NetworkError>) -> Void,
completion: @escaping () -> Void
) {
guard let authManager = authManager else {
// No auth configured — send the entire batch as-is, no JWT.
// If the backend returns 401, this is permanent (no AuthManager to produce a JWT).
networking.send(events: events, path: path, jwt: nil) { result in
if case .failure(.unauthorized) = result {
onGroupResult(events, .failure(.authNotConfigured))
} else {
onGroupResult(events, result)
}
completion()
}
return
}

// Group events by customer identity so each request carries a single JWT.
// Anonymous events (customer nil) are grouped together and sent without JWT.
let grouped = Dictionary(grouping: events) { $0.customer }
let groups = Array(grouped)

if groups.count <= 1, let first = groups.first {
// All events belong to the same customer (or all anonymous) — no splitting needed
sendGroup(
events: events,
path: path,
customerId: first.key,
authManager: authManager,
onGroupResult: onGroupResult,
completion: completion
)
return
}

// Multiple customers — process groups sequentially
func processNext(_ index: Int) {
guard index < groups.count else {
completion()
return
}
let (customerId, groupEvents) = groups[index]
sendGroup(
events: groupEvents,
path: path,
customerId: customerId,
authManager: authManager,
onGroupResult: onGroupResult
) {
processNext(index + 1)
}
}
processNext(0)
}

/// Resolve JWT for a single customer group and send via the networking transport.
private func sendGroup(
events: [OptistreamEvent],
path: String?,
customerId: String?,
authManager: AuthManager,
onGroupResult: @escaping ([OptistreamEvent], Result<Void, NetworkError>) -> Void,
completion: @escaping () -> Void
) {
guard let customerId = customerId, !customerId.isEmpty else {
networking.send(events: events, path: path, jwt: nil) { result in
onGroupResult(events, result)
completion()
}
return
}

authManager.getToken(userId: customerId) { [networking] tokenResult in
switch tokenResult {
case .success(let jwt):
networking.send(events: events, path: path, jwt: jwt) { result in
onGroupResult(events, result)
completion()
}
case .failure(let error):
Logger.error(
"Auth token fetch failed for userId '\(customerId)': \(error.localizedDescription)"
)
onGroupResult(events, .failure(NetworkError.authFailed(error)))
completion()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public final class OptistreamEventBuilder {
category: event.category,
event: event.name,
origin: Constants.Values.origin,
customer: storage.customerID,
customer: storage.customerID.flatMap { $0.isEmpty ? nil : $0 },
visitor: storage.getVisitorID(),
timestamp: Formatter.iso8601withFractionalSeconds.string(from: event.timestamp),
context: JSON(event.context),
Expand Down
Loading
Loading