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
2 changes: 1 addition & 1 deletion Sources/UID2/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@ public struct Environment: Hashable, Sendable {

/// A custom endpoint
public static func custom(url: URL) -> Self {
Self.init(endpoint: url)
Self(endpoint: url)
}
}
72 changes: 51 additions & 21 deletions Sources/UID2/UID2Manager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,12 @@ public final actor UID2Manager {
/// Logger
private let log: OSLog

private let dateGenerator: DateGenerator

// MARK: - Defaults

/// Default UID2 Server URL
/// Override default by setting `UID2ApiUrl` in app's Info.plist
/// https://github.com/IABTechLab/uid2docs/tree/main/api/v2#environments
private let defaultUid2ApiUrl = "https://prod.uidapi.com"

private init() {
// App Supplied Properites
internal init() {
// App Supplied Properties
let environment: Environment
if let apiUrlOverride = Bundle.main.object(forInfoDictionaryKey: "UID2ApiUrl") as? String,
!apiUrlOverride.isEmpty,
Expand All @@ -72,26 +69,43 @@ public final actor UID2Manager {
environment = UID2Settings.shared.environment
}

sdkVersion = UID2SDKProperties.getUID2SDKVersion()
let sdkVersion = UID2SDKProperties.getUID2SDKVersion()
let clientVersion = "\(sdkVersion.major).\(sdkVersion.minor).\(sdkVersion.patch)"

let isLoggingEnabled = UID2Settings.shared.isLoggingEnabled
self.log = isLoggingEnabled
? .init(subsystem: "com.uid2", category: "UID2Manager")
let log = isLoggingEnabled
? OSLog(subsystem: "com.uid2", category: "UID2Manager")
: .disabled
uid2Client = UID2Client(
sdkVersion: clientVersion,
isLoggingEnabled: isLoggingEnabled,
environment: environment

self.init(
uid2Client: UID2Client(
sdkVersion: clientVersion,
isLoggingEnabled: isLoggingEnabled,
environment: environment
),
sdkVersion: sdkVersion,
log: log
)
}

internal init(
uid2Client: UID2Client,
sdkVersion: (major: Int, minor: Int, patch: Int),
log: OSLog,
dateGenerator: DateGenerator = .init { Date() }
) {
self.uid2Client = uid2Client
self.sdkVersion = sdkVersion
self.log = log
self.dateGenerator = dateGenerator

// Try to load from Keychain if available
// Use case for app manually stopped and re-opened
Task {
await loadStateFromDisk()
}
}

// MARK: - Public Identity Lifecycle

// iOS Way to Provide Initial Setup from Outside
Expand Down Expand Up @@ -203,8 +217,8 @@ public final actor UID2Manager {
}
}

private func hasExpired(expiry: Int64, now: Int64 = Date().millisecondsSince1970) async -> Bool {
return expiry <= now
private func hasExpired(expiry: Int64) async -> Bool {
return expiry <= dateGenerator.now.millisecondsSince1970
}

private func getIdentityPackage(identity: UID2Identity?) async -> IdentityPackage {
Expand Down Expand Up @@ -240,17 +254,17 @@ public final actor UID2Manager {
private func validateAndSetIdentity(identity: UID2Identity?, status: IdentityStatus?, statusText: String?) async -> UID2Identity? {

// Process Opt Out
if let status = status, status == .optOut {
if let status, status == .optOut {
os_log("User opt-out detected", log: log, type: .debug)
self.identity = nil
self.identityStatus = .optOut
self.identityStatus = status
let identityPackageOptOut = IdentityPackage(valid: false, errorMessage: "User Opted Out", identity: nil, status: .optOut)
await keychainManager.deleteIdentityFromKeychain()
await keychainManager.saveIdentityToKeychain(identityPackageOptOut)
return nil
}

if let status = status, status == .established {
if let status, status == .established {
self.identity = identity
self.identityStatus = status
// Not needed for loadFromDisk, but is needed for initial setting of Identity
Expand Down Expand Up @@ -354,7 +368,7 @@ public final actor UID2Manager {
/// - Parameter futureCompletionTime: The time in milliseconds to end the
/// - Returns: Delay in nanonseconds (UInt64) or 0 if futureCompletionTime is less than now
private func calculateDelay(futureCompletionTime: Int64) async -> UInt64 {
let now = Date().millisecondsSince1970
let now = dateGenerator.now.millisecondsSince1970
if futureCompletionTime < now {
return UInt64(0)
}
Expand Down Expand Up @@ -383,5 +397,21 @@ public final actor UID2Manager {
}

}
}

internal struct DateGenerator {
private var generate: () -> Date

init(_ generate: @escaping () -> Date) {
self.generate = generate
}

var now: Date {
get {
generate()
}
set {
generate = { newValue }
}
}
}
66 changes: 66 additions & 0 deletions Tests/UID2Tests/TestExtensions/TestCryptoUtil.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// TestCryptoUtil.swift
//
//
// Created by Dave Snabel-Caunt on 16/07/2024.
//

import CryptoKit
import Foundation
@testable import UID2

// Simple Atomic implementation for test usage
private final class Atomic<Value: Sendable>: @unchecked Sendable {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should these different classes in this file be in their own files?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No hard and fast rule, but the usage here is quirky and while required to capture the symmetric key, shouldnt be encouraged elsewhere in favor of Swift's stronger concurrency mechanisms, so I'm inclined to keep it private.


private let lock = NSRecursiveLock()

private var _value: Value

var value: Value {
get {
lock.lock()
defer { lock.unlock() }
return _value
}
set {
lock.lock()
defer { lock.unlock() }
_value = newValue
}
}

init(_ value: Value) {
_value = value
}
}

/// A test convenience which exposes the Symmetric Key it generates for the client.
/// This key can then be used to encrypt stub responses for the client.
internal final class TestCryptoUtil {
private let atomicSymmetricKey: Atomic<SymmetricKey?>

/// `SymmetricKey` generated by the client, or `nil` if the key has not yet been generated.
var symmetricKey: SymmetricKey? {
atomicSymmetricKey.value
}
let cryptoUtil: CryptoUtil

init() {
let symmetricKey = Atomic<SymmetricKey?>(nil)
self.atomicSymmetricKey = symmetricKey

let crypto = CryptoUtil.liveValue
self.cryptoUtil = CryptoUtil(
// Use the live implementations, but grab the symmetricKey
// so we can use it to encrypt a stub response
parseKey: { string in
let result = try crypto.parseKey(string)
symmetricKey.value = result.0
return result
}, encrypt: { data, key, authenticatedData in
try crypto.encrypt(data, key, authenticatedData)
}
)
}
}

67 changes: 28 additions & 39 deletions Tests/UID2Tests/UID2ClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,32 +179,20 @@ final class UID2ClientTests: XCTestCase {
}
}

func testClientGenerateSuccess() async throws {
// Symmetric key generated by the client
let symmetricKey = Atomic<SymmetricKey?>(nil)
func testClientGenerateServerOptout() async throws {
let testCrypto = TestCryptoUtil()
HTTPStub.shared.stubs = { request in
XCTAssertEqual(request.url?.path, "/v2/token/client-generate")
let responseData = try! FixtureLoader.data(fixture: "refresh-token-200-success-decrypted")
let box = try! AES.GCM.seal(responseData, using: symmetricKey.value!)
let responseData = try! FixtureLoader.data(fixture: "refresh-token-200-optout-decrypted")
let box = try! AES.GCM.seal(responseData, using: testCrypto.symmetricKey!)
let data = box.combined!.base64EncodedData()
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return .success((data, response))
}

let crypto = CryptoUtil.liveValue
let client = UID2Client(
sdkVersion: "1.0",
cryptoUtil: .init(
// Use the live implementations, but grab the symmetricKey
// so we can use it to encrypt a stub response
parseKey: { string in
let result = try crypto.parseKey(string)
symmetricKey.value = result.0
return result
}, encrypt: { data, key, authenticatedData in
try crypto.encrypt(data, key, authenticatedData)
}
)
cryptoUtil: testCrypto.cryptoUtil
)

let result = try await client.generateIdentity(
Expand All @@ -213,31 +201,32 @@ final class UID2ClientTests: XCTestCase {
serverPublicKey: serverPublicKeyString,
appName: "com.example.app"
)
XCTAssertNotNil(result.identity)
XCTAssertNil(result.identity)
XCTAssertEqual(result.status, .optOut)
}
}

// Simple Atomic implementation for test usage
private final class Atomic<Value: Sendable>: @unchecked Sendable {

private let lock = NSRecursiveLock()

private var _value: Value

var value: Value {
get {
lock.lock()
defer { lock.unlock() }
return _value
}
set {
lock.lock()
defer { lock.unlock() }
_value = newValue
func testClientGenerateSuccess() async throws {
let testCrypto = TestCryptoUtil()
HTTPStub.shared.stubs = { request in
XCTAssertEqual(request.url?.path, "/v2/token/client-generate")
let responseData = try! FixtureLoader.data(fixture: "refresh-token-200-success-decrypted")
let box = try! AES.GCM.seal(responseData, using: testCrypto.symmetricKey!)
let data = box.combined!.base64EncodedData()
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return .success((data, response))
}
}

init(_ value: Value) {
_value = value
let client = UID2Client(
sdkVersion: "1.0",
cryptoUtil: testCrypto.cryptoUtil
)

let result = try await client.generateIdentity(
.emailHash("tMmiiTI7IaAcPpQPFQ65uMVCWH8av9jw4cwf/F5HVRQ="),
subscriptionID: "test",
serverPublicKey: serverPublicKeyString,
appName: "com.example.app"
)
XCTAssertNotNil(result.identity)
}
}
Loading