diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml
index 8c6d12f..dad2a19 100644
--- a/.github/workflows/test-pull-request.yml
+++ b/.github/workflows/test-pull-request.yml
@@ -25,10 +25,10 @@ jobs:
run: xcodebuild -scheme UID2 -destination "generic/platform=tvOS"
- name: Run unit tests
- run: xcodebuild test -scheme UID2Tests -sdk iphonesimulator16.2 -destination "OS=16.2,name=iPhone 14"
+ run: xcodebuild test -scheme UID2 -sdk iphonesimulator16.2 -destination "OS=16.2,name=iPhone 14"
- name: Run unit tests on tvOS
- run: xcodebuild test -scheme UID2Tests -sdk appletvsimulator16.1 -destination "OS=16.1,name=Apple TV"
+ run: xcodebuild test -scheme UID2 -sdk appletvsimulator16.1 -destination "OS=16.1,name=Apple TV"
vulnerability-scan:
name: Vulnerability Scan
diff --git a/.swiftlint.yml b/.swiftlint.yml
index e95fa2a..d56d2bd 100644
--- a/.swiftlint.yml
+++ b/.swiftlint.yml
@@ -18,10 +18,13 @@ line_length:
ignores_comments: true
ignores_urls: true
+nesting:
+ type_level:
+ warning: 2
+
disabled_rules:
- unused_optional_binding
- trailing_whitespace
-
+ - function_parameter_count
opt_in_rules:
- sorted_imports
-
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/UID2Tests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/UID2Tests.xcscheme
deleted file mode 100644
index f9885e5..0000000
--- a/.swiftpm/xcode/xcshareddata/xcschemes/UID2Tests.xcscheme
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp.xcodeproj/project.pbxproj b/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp.xcodeproj/project.pbxproj
index f205e87..274869c 100644
--- a/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp.xcodeproj/project.pbxproj
+++ b/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp.xcodeproj/project.pbxproj
@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
+ BFF0F31A2BC6D7E7002646FE /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = BFF0F3192BC6D7E7002646FE /* README.md */; };
E291757C29919C3900500573 /* AppUID2Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = E291757B29919C3900500573 /* AppUID2Client.swift */; };
E291757F2991B19F00500573 /* UID2ServerCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = E291757E2991B19F00500573 /* UID2ServerCredentials.swift */; };
E29175812991B22200500573 /* UID2ServerCredentials.json in Resources */ = {isa = PBXBuildFile; fileRef = E29175802991B22200500573 /* UID2ServerCredentials.json */; };
@@ -26,6 +27,7 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
+ BFF0F3192BC6D7E7002646FE /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; };
E291757A298C1CB900500573 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; };
E291757B29919C3900500573 /* AppUID2Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUID2Client.swift; sourceTree = ""; };
E291757E2991B19F00500573 /* UID2ServerCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UID2ServerCredentials.swift; sourceTree = ""; };
@@ -103,6 +105,7 @@
E2E19004298833A600FFE8C3 /* Assets.xcassets */,
E2E19006298833A600FFE8C3 /* Preview Content */,
E2E190152988566E00FFE8C3 /* Localizable.strings */,
+ BFF0F3192BC6D7E7002646FE /* README.md */,
);
path = UID2SDKDevelopmentApp;
sourceTree = "";
@@ -194,6 +197,7 @@
files = (
E2E19008298833A600FFE8C3 /* Preview Assets.xcassets in Resources */,
E2E190162988566E00FFE8C3 /* Localizable.strings in Resources */,
+ BFF0F31A2BC6D7E7002646FE /* README.md in Resources */,
E2E19005298833A600FFE8C3 /* Assets.xcassets in Resources */,
E29175812991B22200500573 /* UID2ServerCredentials.json in Resources */,
);
diff --git a/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 0000000..4e1016a
--- /dev/null
+++ b/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,32 @@
+{
+ "pins" : [
+ {
+ "identity" : "swift-asn1",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-asn1.git",
+ "state" : {
+ "revision" : "c7e239b5c1492ffc3ebd7fbcc7a92548ce4e78f0",
+ "version" : "1.1.0"
+ }
+ },
+ {
+ "identity" : "swift-certificates",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-certificates.git",
+ "state" : {
+ "revision" : "bc566f88842b3b8001717326d935c2d113af5741",
+ "version" : "1.2.0"
+ }
+ },
+ {
+ "identity" : "swift-crypto",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-crypto.git",
+ "state" : {
+ "revision" : "f0525da24dc3c6cbb2b6b338b65042bc91cbc4bb",
+ "version" : "3.3.0"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/IdentityPackageListView.swift b/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/IdentityPackageListView.swift
index f692185..34c0812 100644
--- a/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/IdentityPackageListView.swift
+++ b/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/IdentityPackageListView.swift
@@ -21,11 +21,8 @@ struct IdentityPackageListView: View {
Section(header: Text(LocalizedStringKey("root.title.identitypackage"))
.font(Font.system(size: 22, weight: .bold))) {
IdentityPackageView(viewModel)
- }
- Section(header: Text(LocalizedStringKey("root.title.identitypackage.notifications"))
- .font(Font.system(size: 22, weight: .bold))) {
IdentityPackageNotificationsView(viewModel)
- }
+ }
}.listStyle(.plain)
}
}
diff --git a/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/IdentityPackageNotificationsView.swift b/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/IdentityPackageNotificationsView.swift
index a6372d0..5e3eb55 100644
--- a/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/IdentityPackageNotificationsView.swift
+++ b/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/IdentityPackageNotificationsView.swift
@@ -20,8 +20,19 @@ struct IdentityPackageNotificationsView: View {
Group {
Text(LocalizedStringKey("root.label.identitypackage.notification.publishedStatus"))
.font(Font.system(size: 18, weight: .bold))
- Text(viewModel.identityStatus.debugDescription)
+ Text(status)
.font(Font.system(size: 16, weight: .regular))
}
}
+
+ private var status: String {
+ if let identityStatus = viewModel.identityStatus {
+ if case .noIdentity = identityStatus {
+ return NSLocalizedString("common.nil", comment: "")
+ }
+ return identityStatus.debugDescription
+ }
+
+ return NSLocalizedString("common.nil", comment: "")
+ }
}
diff --git a/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/Localizable.strings b/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/Localizable.strings
index 0734309..48024b2 100644
--- a/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/Localizable.strings
+++ b/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/Localizable.strings
@@ -6,11 +6,10 @@
*/
-"common.uid2sdk" = "UID2 SDK";
"common.nil" = "Nil";
-"root.title.identitypackage" = "Current Identity Package";
-"root.title.identitypackage.notifications" = "Notifications";
+"root.navigation.title" = "UID2 SDK Dev App";
+"root.title.identitypackage" = "Current Identity";
"root.label.error" = "Error Occurred";
"root.button.reset" = "Reset";
"root.button.refresh" = "Manual Refresh";
@@ -22,4 +21,4 @@
"root.label.identitypackage.refreshExpires" = "Refresh Expires";
"root.label.identitypackage.refreshResponseKey" = "Refresh Response Key";
-"root.label.identitypackage.notification.publishedStatus" = "Published Status";
+"root.label.identitypackage.notification.publishedStatus" = "Identity Status";
diff --git a/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/README.md b/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/README.md
new file mode 100644
index 0000000..b06cae4
--- /dev/null
+++ b/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/README.md
@@ -0,0 +1,9 @@
+# UID2 iOS SDK Development App
+
+## Developer Overrides
+
+You can set a different API Endpoint via `UID2ApiUrl` in `Info.plist`, i.e.
+
+```
+plutil -replace UID2ApiUrl -string "https://operator-integ.uidapi.com" Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/Info.plist
+```
diff --git a/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/RootView.swift b/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/RootView.swift
index 3a0ab55..6dfd28b 100644
--- a/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/RootView.swift
+++ b/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/RootView.swift
@@ -14,36 +14,60 @@ struct RootView: View {
private var viewModel = RootViewModel()
@State
- private var emailTextField = ""
-
+ private var email = ""
+
+ @State
+ private var phone = ""
+
+ @State
+ private var isClientSide = true
+
var body: some View {
VStack {
- Text(viewModel.titleText)
+ Text("root.navigation.title")
.font(Font.system(size: 28, weight: .bold))
- .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
- TextField("Email Address", text: $emailTextField)
- .textFieldStyle(.roundedBorder)
- .textCase(.lowercase)
- .textContentType(.emailAddress)
- .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
- .onSubmit {
- viewModel.handleEmailEntry(emailTextField.lowercased())
+ HStack {
+ TextField("Email Address", text: $email)
+ .keyboardType(.emailAddress)
+ .textInputAutocapitalization(.never)
+ .textContentType(.emailAddress)
+ Button("Submit Email", systemImage: "arrow.right.circle.fill") {
+ viewModel.handleEmailEntry(email, clientSide: isClientSide)
}
+ .labelStyle(.iconOnly)
+ }
+ HStack {
+ TextField("Phone", text: $phone)
+ .keyboardType(.phonePad)
+ .textContentType(.telephoneNumber)
+ Button("Submit Phone", systemImage: "arrow.right.circle.fill") {
+ viewModel.handlePhoneEntry(phone, clientSide: isClientSide)
+ }
+ .labelStyle(.iconOnly)
+ }
+ Toggle(isOn: $isClientSide) {
+ Label("Client Side", systemImage: isClientSide ? "circle.fill" : "circle.slash")
+ }
+ .toggleStyle(.button)
+ .frame(height: 32)
if viewModel.error != nil {
ErrorListView(viewModel)
} else {
IdentityPackageListView(viewModel)
}
HStack(alignment: .center, spacing: 20.0) {
- Button(LocalizedStringKey("root.button.reset")) {
- viewModel.handleResetButton()
- emailTextField = ""
+ Button("root.button.reset") {
+ viewModel.reset()
}.padding()
- Button(LocalizedStringKey("root.button.refresh")) {
- viewModel.handleRefreshButton()
+ Button("root.button.refresh") {
+ viewModel.refresh()
}.padding()
}
}
+ .textFieldStyle(.roundedBorder)
+ .autocorrectionDisabled()
+ .imageScale(.large)
+ .padding()
}
}
diff --git a/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/RootViewModel.swift b/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/RootViewModel.swift
index 80e801e..9a33b7b 100644
--- a/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/RootViewModel.swift
+++ b/Development/UID2SDKDevelopmentApp/UID2SDKDevelopmentApp/RootViewModel.swift
@@ -13,8 +13,11 @@ import UID2
@MainActor
class RootViewModel: ObservableObject {
- @Published private(set) var titleText = LocalizedStringKey("common.uid2sdk")
- @Published private(set) var uid2Identity: UID2Identity?
+ @Published private(set) var uid2Identity: UID2Identity? {
+ didSet {
+ error = nil
+ }
+ }
@Published private(set) var identityStatus: IdentityStatus?
@Published private(set) var error: Error?
@@ -24,7 +27,7 @@ class RootViewModel: ObservableObject {
init() {
UID2Settings.shared.isLoggingEnabled = true
- UID2Settings.shared.environment = .oregon
+
Task {
await UID2Manager.shared.$identity
.receive(on: DispatchQueue.main)
@@ -84,30 +87,79 @@ class RootViewModel: ObservableObject {
// MARK: - UX Handling Functions
- func handleEmailEntry(_ emailAddress: String) {
+ func handleEmailEntry(_ email: String, clientSide: Bool) {
+ Task {
+ self.error = nil
+ if clientSide {
+ struct InvalidEmailError: Error, LocalizedError {
+ var errorDescription: String = "Invalid email address"
+ }
+ guard let normalizedEmail = IdentityType.NormalizedEmail(string: email) else {
+ error = InvalidEmailError()
+ return
+ }
+ clientSideGenerate(identity: .email(normalizedEmail))
+ } else {
+ generateIdentity(email, requestType: .email)
+ }
+ }
+ }
+
+ func handlePhoneEntry(_ phone: String, clientSide: Bool) {
+ self.error = nil
+ if clientSide {
+ struct InvalidPhoneError: Error, LocalizedError {
+ var errorDescription: String = "Phone number is not normalized"
+ }
+ guard let normalizedPhone = IdentityType.NormalizedPhone(normalized: phone) else {
+ error = InvalidPhoneError()
+ return
+ }
+ clientSideGenerate(identity: .phone(normalizedPhone))
+ } else {
+ generateIdentity(phone, requestType: .phone)
+ }
+ }
+
+ func clientSideGenerate(identity: IdentityType) {
+ let subscriptionID = "toPh8vgJgt"
+ // swiftlint:disable:next line_length
+ let serverPublicKeyString = "UID2-X-I-MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKAbPfOz7u25g1fL6riU7p2eeqhjmpALPeYoyjvZmZ1xM2NM8UeOmDZmCIBnKyRZ97pz5bMCjrs38WM22O7LJuw=="
+
+ Task {
+ do {
+ try await UID2Manager.shared.generateIdentity(
+ identity,
+ subscriptionID: subscriptionID,
+ serverPublicKey: serverPublicKeyString,
+ appName: Bundle.main.bundleIdentifier!
+ )
+ } catch {
+ self.error = error
+ }
+ }
+ }
+
+ func generateIdentity(_ identity: String, requestType: AppUID2Client.RequestTypes) {
Task {
do {
- guard let identity = try await apiClient.generateIdentity(requestString: emailAddress, requestType: .email) else {
+ guard let identity = try await apiClient.generateIdentity(requestString: identity, requestType: requestType) else {
return
}
await UID2Manager.shared.setIdentity(identity)
- DispatchQueue.main.async {
- self.error = nil
- }
} catch {
self.error = error
}
}
}
-
- func handleResetButton() {
+
+ func reset() {
Task {
await UID2Manager.shared.resetIdentity()
- self.error = nil
}
}
- func handleRefreshButton() {
+ func refresh() {
Task {
await UID2Manager.shared.refreshIdentity()
}
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..4e1016a
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,32 @@
+{
+ "pins" : [
+ {
+ "identity" : "swift-asn1",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-asn1.git",
+ "state" : {
+ "revision" : "c7e239b5c1492ffc3ebd7fbcc7a92548ce4e78f0",
+ "version" : "1.1.0"
+ }
+ },
+ {
+ "identity" : "swift-certificates",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-certificates.git",
+ "state" : {
+ "revision" : "bc566f88842b3b8001717326d935c2d113af5741",
+ "version" : "1.2.0"
+ }
+ },
+ {
+ "identity" : "swift-crypto",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-crypto.git",
+ "state" : {
+ "revision" : "f0525da24dc3c6cbb2b6b338b65042bc91cbc4bb",
+ "version" : "3.3.0"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/Package.swift b/Package.swift
index f96709e..81b1b6c 100644
--- a/Package.swift
+++ b/Package.swift
@@ -16,11 +16,12 @@ let package = Package(
targets: ["UID2"]),
],
dependencies: [
+ .package(url: "https://github.com/apple/swift-certificates.git", .upToNextMajor(from: "1.0.0"))
],
targets: [
.target(
name: "UID2",
- dependencies: [],
+ dependencies: [ .product(name: "X509", package: "swift-certificates") ],
resources: [
.copy("Properties/sdk_properties.plist"),
.copy("PrivacyInfo.xcprivacy")
diff --git a/Package@swift-5.8.swift b/Package@swift-5.8.swift
index cb4f0d6..19cd1f1 100644
--- a/Package@swift-5.8.swift
+++ b/Package@swift-5.8.swift
@@ -7,7 +7,8 @@ let package = Package(
name: "UID2",
defaultLocalization: "en",
platforms: [
- .iOS(.v13)
+ .iOS(.v13),
+ .tvOS(.v13)
],
products: [
.library(
@@ -15,11 +16,12 @@ let package = Package(
targets: ["UID2"]),
],
dependencies: [
+ .package(url: "https://github.com/apple/swift-certificates.git", .upToNextMajor(from: "1.0.0"))
],
targets: [
.target(
name: "UID2",
- dependencies: [],
+ dependencies: [ .product(name: "X509", package: "swift-certificates") ],
resources: [
.copy("Properties/sdk_properties.plist"),
.copy("PrivacyInfo.xcprivacy")
diff --git a/Sources/UID2/CryptoUtil.swift b/Sources/UID2/CryptoUtil.swift
new file mode 100644
index 0000000..adbfb5e
--- /dev/null
+++ b/Sources/UID2/CryptoUtil.swift
@@ -0,0 +1,66 @@
+//
+// CryptoUtil.swift
+//
+//
+// Created by Dave Snabel-Caunt on 18/04/2024.
+//
+
+import CryptoKit
+import Foundation
+import SwiftASN1
+import X509
+
+struct CryptoUtil: Sendable {
+ // Parses a server's public key and returns a newly generated public key and symmetric key.
+ var parseKey: @Sendable (_ string: String) throws -> (SymmetricKey, P256.KeyAgreement.PublicKey)
+
+ // Encrypts data using a symmetric key and authenticated data
+ var encrypt: @Sendable (_ data: Data, _ key: SymmetricKey, _ authenticatedData: Data) throws -> AES.GCM.SealedBox
+}
+
+extension CryptoUtil {
+ private static let serverPublicKeyPrefixLength = 9
+
+ static var liveValue: Self {
+ Self(
+ parseKey: { str in
+ let serverPublicKey = try publicKey(string: str)
+ return try symmetricKey(serverPublicKey: serverPublicKey)
+ },
+ encrypt: { data, key, authenticatedData in
+ return try AES.GCM.seal(data, using: key, authenticating: authenticatedData)
+ }
+ )
+ }
+
+ /// Public key from server string representation given to integrator
+ private static func publicKey(string: String) throws -> P256.KeyAgreement.PublicKey {
+ // Server public key is provided with a 9 byte prefix. The remainder is base64 encoded.
+ let encodedKey = Data(string.utf8.dropFirst(serverPublicKeyPrefixLength))
+ guard let decodedSPKI = Data(base64Encoded: encodedKey) else {
+ throw UID2Error.configuration(message: "Invalid server key as base64")
+ }
+
+ let result = try DER.parse(Array(decodedSPKI))
+ let publicKey = try Certificate.PublicKey(derEncoded: result)
+
+ let privateKeyData = publicKey.subjectPublicKeyInfoBytes
+ do {
+ return try P256.KeyAgreement.PublicKey(x963Representation: privateKeyData)
+ } catch {
+ throw UID2Error.configuration(message: "Invalid server key representation")
+ }
+ }
+
+ /// Generates client keys, and a symmetric key in agreement with the API server's public key.
+ private static func symmetricKey(
+ serverPublicKey: P256.KeyAgreement.PublicKey
+ ) throws -> (SymmetricKey, P256.KeyAgreement.PublicKey) {
+ // Generate our public/private key pair
+ let privateKey = P256.KeyAgreement.PrivateKey()
+ let secret = try privateKey.sharedSecretFromKeyAgreement(with: serverPublicKey)
+ // Use secret as key
+ let symmetricKey = secret.withUnsafeBytes(SymmetricKey.init(data:))
+ return (symmetricKey, privateKey.publicKey)
+ }
+}
diff --git a/Sources/UID2/Data/IdentityPackage.swift b/Sources/UID2/Data/IdentityPackage.swift
index 51b8e6a..b21aad0 100644
--- a/Sources/UID2/Data/IdentityPackage.swift
+++ b/Sources/UID2/Data/IdentityPackage.swift
@@ -72,14 +72,12 @@ extension IdentityPackage: Codable {
extension IdentityPackage {
static func fromData(_ data: Data) -> IdentityPackage? {
- let decoder = JSONDecoder()
- decoder.keyDecodingStrategy = .convertFromSnakeCase
+ let decoder = JSONDecoder.apiDecoder()
return try? decoder.decode(IdentityPackage.self, from: data)
}
func toData() throws -> Data {
- let encoder = JSONEncoder()
- encoder.keyEncodingStrategy = .convertToSnakeCase
+ let encoder = JSONEncoder.apiEncoder()
return try encoder.encode(self)
}
diff --git a/Sources/UID2/Data/IdentityType.swift b/Sources/UID2/Data/IdentityType.swift
new file mode 100644
index 0000000..9c85fdb
--- /dev/null
+++ b/Sources/UID2/Data/IdentityType.swift
@@ -0,0 +1,167 @@
+//
+// IdentityType.swift
+//
+//
+// Created by Dave Snabel-Caunt on 16/04/2024.
+//
+
+import Foundation
+
+public enum IdentityType: Hashable, Sendable {
+ case email(NormalizedEmail)
+ case emailHash(String)
+ case phone(NormalizedPhone)
+ case phoneHash(String)
+}
+
+extension IdentityType {
+ var value: String {
+ switch self {
+ case .email(let email):
+ return email.value
+ case .phone(let phone):
+ return phone.value
+ case .emailHash(let value),
+ .phoneHash(let value):
+ return value
+ }
+ }
+}
+
+extension IdentityType {
+
+ /// https://unifiedid.com/docs/getting-started/gs-normalization-encoding#email-address-normalization
+ public struct NormalizedEmail: Hashable, Sendable, CustomStringConvertible {
+
+ public let value: String
+
+ /// Creates a Normalized Email from a raw email string.
+ public init?(string: String) {
+ guard let normalized = Self.normalize(email: string) else {
+ return nil
+ }
+ self.value = normalized
+ }
+
+ public var description: String {
+ value
+ }
+
+ // Ported from the Android SDK to maintain the same behavior
+ // https://github.com/IABTechLab/uid2-android-sdk
+ // swiftlint:disable:next cyclomatic_complexity
+ private static func normalize(email: String) -> String? {
+ enum ParsingState {
+ case starting
+ case subDomain
+ }
+
+ var preSubDomain = ""
+ var preSubDomainSpecialized = ""
+ var subDomain = ""
+ var subDomainWhiteSpace = ""
+
+ var state = ParsingState.starting
+ var inExtension = false
+
+ let email = email.lowercased(with: .current)
+
+ charLoop: for char in email {
+ switch state {
+ case .starting:
+ guard char != " " else {
+ continue charLoop
+ }
+ if char == "@" {
+ state = .subDomain
+ } else if char == "." {
+ preSubDomain.append(char)
+ } else if char == "+" {
+ preSubDomain.append(char)
+ inExtension = true
+ } else {
+ preSubDomain.append(char)
+ if !inExtension {
+ preSubDomainSpecialized.append(char)
+ }
+ }
+ case .subDomain:
+ guard char != "@" else {
+ return nil
+ }
+
+ guard char != " " else {
+ subDomainWhiteSpace.append(char)
+ continue
+ }
+
+ if !subDomainWhiteSpace.isEmpty {
+ subDomain.append(subDomainWhiteSpace)
+ subDomainWhiteSpace = ""
+ }
+
+ subDomain.append(char)
+ }
+ }
+
+ // Verify that we've parsed the subdomain correctly.
+ guard !subDomain.isEmpty else {
+ return nil
+ }
+
+ // Verify that we've parsed the address part correctly.
+ let addressPartToUse: String
+ if "gmail.com" == subDomain {
+ addressPartToUse = preSubDomainSpecialized
+ } else {
+ addressPartToUse = preSubDomain
+ }
+
+ guard !addressPartToUse.isEmpty else {
+ return nil
+ }
+
+ // Build the normalized version of the email address.
+ return "\(addressPartToUse)@\(subDomain)"
+ }
+ }
+}
+
+extension IdentityType {
+
+ /// https://unifiedid.com/docs/getting-started/gs-normalization-encoding#email-address-normalization
+ public struct NormalizedPhone: Hashable, Sendable, CustomStringConvertible {
+
+ public let value: String
+
+ /// Creates a Normalized Phone value from a normalized phone string.
+ /// Returns `nil` if `normalized` is not already normalized according to ITU E.164.
+ public init?(normalized: String) {
+ guard Self.isNormalized(phone: normalized) else {
+ return nil
+ }
+ self.value = normalized
+ }
+
+ public var description: String {
+ value
+ }
+
+ /// Returns true if the string is normalized according to ITU E.164 Standard (https://en.wikipedia.org/wiki/E.164).
+ private static func isNormalized(phone: String) -> Bool {
+ guard phone.first == "+" else {
+ return false
+ }
+
+ let count = phone.count
+ guard count >= 11 && count <= 16 else {
+ return false
+ }
+ let firstIndex = phone.index(phone.startIndex, offsetBy: 1)
+ let number = phone[firstIndex...]
+ return number.allSatisfy { char in
+ char >= "0" && char <= "9"
+ }
+ }
+ }
+}
diff --git a/Sources/UID2/Data/UID2Identity.swift b/Sources/UID2/Data/UID2Identity.swift
index f7f7035..28a2b55 100644
--- a/Sources/UID2/Data/UID2Identity.swift
+++ b/Sources/UID2/Data/UID2Identity.swift
@@ -8,7 +8,6 @@
import Foundation
public struct UID2Identity: Hashable, Sendable, Codable {
-
public let advertisingToken: String
public let refreshToken: String
public let identityExpires: Int64
@@ -37,14 +36,12 @@ public struct UID2Identity: Hashable, Sendable, Codable {
extension UID2Identity {
static func fromData(_ data: Data) -> UID2Identity? {
- let decoder = JSONDecoder()
- decoder.keyDecodingStrategy = .convertFromSnakeCase
+ let decoder = JSONDecoder.apiDecoder()
return try? decoder.decode(UID2Identity.self, from: data)
}
func toData() throws -> Data {
- let encoder = JSONEncoder()
- encoder.keyEncodingStrategy = .convertToSnakeCase
+ let encoder = JSONEncoder.apiEncoder()
return try encoder.encode(self)
}
diff --git a/Sources/UID2/Extensions/PublicKey+Extensions.swift b/Sources/UID2/Extensions/PublicKey+Extensions.swift
new file mode 100644
index 0000000..ea9a938
--- /dev/null
+++ b/Sources/UID2/Extensions/PublicKey+Extensions.swift
@@ -0,0 +1,26 @@
+//
+// PublicKey+Extensions.swift
+//
+//
+// Created by Dave Snabel-Caunt on 19/04/2024.
+//
+
+import CryptoKit
+import Foundation
+import SwiftASN1
+import X509
+
+extension P256.KeyAgreement.PublicKey {
+ // CryptoKit's implementation is only available in iOS 14
+ var derRepresentation: Data {
+ get throws {
+ // Signing and KeyAgreement keys are just keys – `but swift-certificates` only supports
+ // encoding a `P256.Signing.PublicKey`. Convert the key type, and encode.
+ let signingKey = try P256.Signing.PublicKey(rawRepresentation: self.rawRepresentation)
+ let publicKey = Certificate.PublicKey(signingKey)
+ var serializer = DER.Serializer()
+ try serializer.serialize(publicKey)
+ return Data(serializer.serializedBytes)
+ }
+ }
+}
diff --git a/Sources/UID2/Extensions/URLSession+Extensions.swift b/Sources/UID2/Extensions/URLSession+Extensions.swift
index 9f9a7df..e7c0b09 100644
--- a/Sources/UID2/Extensions/URLSession+Extensions.swift
+++ b/Sources/UID2/Extensions/URLSession+Extensions.swift
@@ -9,14 +9,12 @@ import Foundation
extension URLSession: NetworkSession {
- func loadData(for request: URLRequest) async throws -> (Data, Int) {
+ func loadData(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await data(for: request)
-
- guard let httpResponse = response as? HTTPURLResponse else {
- throw UID2Error.httpURLResponse
- }
-
- return (data, httpResponse.statusCode)
+ // `URLResponse` is always `HTTPURLResponse` for HTTP requests
+ // https://developer.apple.com/documentation/foundation/urlresponse
+ // swiftlint:disable:next force_cast
+ return (data, response as! HTTPURLResponse)
}
}
diff --git a/Sources/UID2/Networking/ClientGenerate.swift b/Sources/UID2/Networking/ClientGenerate.swift
new file mode 100644
index 0000000..8b361b2
--- /dev/null
+++ b/Sources/UID2/Networking/ClientGenerate.swift
@@ -0,0 +1,95 @@
+//
+// ClientGenerate.swift
+//
+//
+// Created by Dave Snabel-Caunt on 11/04/2024.
+//
+
+import CryptoKit
+import Foundation
+
+extension Request {
+ static func clientGenerate(
+ payload: Data,
+ initializationVector: Data,
+ publicKey: P256.KeyAgreement.PublicKey,
+ subscriptionID: String,
+ timestamp: Int,
+ appName: String
+ ) throws -> Request {
+ let requestBody = ClientGenerateRequestBody(
+ payload: payload.base64EncodedString(),
+ initializationVector: initializationVector.base64EncodedString(),
+ publicKey: try publicKey.derRepresentation.base64EncodedString(),
+ subscriptionID: subscriptionID,
+ timestamp: timestamp,
+ appName: appName
+ )
+
+ let encoder = JSONEncoder.apiEncoder()
+ let body = try encoder.encode(requestBody)
+
+ return .init(
+ path: "/v2/token/client-generate",
+ method: .post,
+ body: body
+ )
+ }
+}
+
+struct ClientGeneratePayload: Encodable {
+ var key: CodingKeys
+ var value: String
+
+ enum CodingKeys: CodingKey {
+ case emailHash
+ case phoneHash
+ case optoutCheck
+ }
+
+ func encode(to encoder: any Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(value, forKey: key)
+ try container.encode(1, forKey: .optoutCheck)
+ }
+}
+
+extension ClientGeneratePayload {
+ init(_ identity: IdentityType) {
+ switch identity {
+ case .email(let email):
+ self.init(key: .emailHash, value: email.value.sha256hash().base64EncodedString())
+ case .emailHash(let hash):
+ self.init(key: .emailHash, value: hash)
+ case .phone(let phone):
+ self.init(key: .phoneHash, value: phone.value.sha256hash().base64EncodedString())
+ case .phoneHash(let hash):
+ self.init(key: .phoneHash, value: hash)
+ }
+ }
+}
+
+struct ClientGenerateRequestBody: Encodable {
+ var payload: String
+ var initializationVector: String
+ var publicKey: String
+ var subscriptionID: String
+ var timestamp: Int
+ var appName: String
+
+ enum CodingKeys: String, CodingKey {
+ case payload
+ case initializationVector = "iv"
+ case publicKey
+ case subscriptionID
+ case timestamp
+ case appName
+ }
+}
+
+fileprivate extension String {
+ func sha256hash() -> Data {
+ let digest = SHA256.hash(data: Data(self.utf8))
+ return Data(digest)
+ }
+}
diff --git a/Sources/UID2/Networking/Codable.swift b/Sources/UID2/Networking/Codable.swift
new file mode 100644
index 0000000..694c5e1
--- /dev/null
+++ b/Sources/UID2/Networking/Codable.swift
@@ -0,0 +1,24 @@
+//
+// Codable.swift
+//
+//
+// Created by Dave Snabel-Caunt on 10/04/2024.
+//
+
+import Foundation
+
+extension JSONDecoder {
+ static func apiDecoder() -> JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.keyDecodingStrategy = .convertFromSnakeCase
+ return decoder
+ }
+}
+
+extension JSONEncoder {
+ static func apiEncoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.keyEncodingStrategy = .convertToSnakeCase
+ return encoder
+ }
+}
diff --git a/Sources/UID2/Networking/DataEnvelope.swift b/Sources/UID2/Networking/DataEnvelope.swift
index 92be4b0..389ce31 100644
--- a/Sources/UID2/Networking/DataEnvelope.swift
+++ b/Sources/UID2/Networking/DataEnvelope.swift
@@ -8,43 +8,45 @@
import CryptoKit
import Foundation
-internal final class DataEnvelope {
-
- static func decrypt(_ key: String, _ responseData: Data, _ isRefresh: Bool = false) -> Data? {
+internal enum DataEnvelope {
- // Confirm that responseData is Base64
- guard let base64String = String(data: responseData, encoding: .utf8),
- let decodedData = Data(base64Encoded: base64String, options: .ignoreUnknownCharacters) else {
- return responseData
- }
-
- // Decrypt Data
- guard let secretData = Data(base64Encoded: key) else {
- return nil
- }
+ /// Decrypts raw response envelope data, which is expected to be a base64 encoded string.
+ /// - Parameters:
+ /// - data: Encrypted data, in base64
+ /// - key: A SymmetricKey used for decryption
+ /// - includesNonce: Whether the encrypted data is prefixed by a 16 byte nonce
+ /// - Returns: Decrypted data
+ static func decrypt(_ data: Data, key: SymmetricKey, includesNonce: Bool = false) -> Data? {
+ // Confirm that responseData is Base64
+ guard let decodedData = Data(base64EncodedData: data, options: .ignoreUnknownCharacters) else {
+ return nil
+ }
- let key = SymmetricKey(data: secretData)
- var decryptedData: Data?
- do {
- // Both work
- let sealedBox = try AES.GCM.SealedBox(combined: decodedData)
- decryptedData = try AES.GCM.open(sealedBox, using: key)
- } catch {
- // No Op
- }
+ let decryptedData: Data
+ do {
+ // Both work
+ let sealedBox = try AES.GCM.SealedBox(combined: decodedData)
+ decryptedData = try AES.GCM.open(sealedBox, using: key)
+ } catch {
+ return nil
+ }
- guard let decryptedData = decryptedData else {
- return nil
- }
+ // Parse Unencrypted Response Data / Byte Slicing
+ // https://unifiedid.com/docs/getting-started/gs-encryption-decryption#unencrypted-response-data-envelope
+ if includesNonce {
+ return decryptedData[16...]
+ } else {
+ return decryptedData
+ }
+ }
+}
- // Parse Unencrypted Response Data / Byte Slicing
- // https://github.com/UnifiedID2/uid2docs/blob/main/api/v2/encryption-decryption.md#unencrypted-response-data-envelope
- var payload = decryptedData
- if !isRefresh {
- payload = decryptedData.subdata(in: 16.. (Data, Int)
+ func loadData(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
diff --git a/Sources/UID2/PrivacyInfo.xcprivacy b/Sources/UID2/PrivacyInfo.xcprivacy
index 6d25eca..b6af7f4 100644
--- a/Sources/UID2/PrivacyInfo.xcprivacy
+++ b/Sources/UID2/PrivacyInfo.xcprivacy
@@ -16,6 +16,30 @@
NSPrivacyCollectedDataTypePurposeAnalytics
+
+ NSPrivacyCollectedDataType
+ NSPrivacyCollectedDataTypeEmailAddress
+ NSPrivacyCollectedDataTypeLinked
+
+ NSPrivacyCollectedDataTypeTracking
+
+ NSPrivacyCollectedDataTypePurposes
+
+
+
+
+
+ NSPrivacyCollectedDataType
+ NSPrivacyCollectedDataTypePhoneNumber
+ NSPrivacyCollectedDataTypeLinked
+
+ NSPrivacyCollectedDataTypeTracking
+
+ NSPrivacyCollectedDataTypePurposes
+
+
+
+
NSPrivacyCollectedDataType
NSPrivacyCollectedDataTypeOtherDataTypes
diff --git a/Sources/UID2/UID2Client.swift b/Sources/UID2/UID2Client.swift
index 750179f..579d94e 100644
--- a/Sources/UID2/UID2Client.swift
+++ b/Sources/UID2/UID2Client.swift
@@ -5,7 +5,10 @@
// Created by Brad Leege on 1/31/23.
//
+import CryptoKit
import Foundation
+import SwiftASN1
+import X509
// https://forums.developer.apple.com/forums/thread/747816
#if swift(>=6.0)
@@ -22,12 +25,14 @@ internal final class UID2Client: Sendable {
private let session: NetworkSession
private let log: OSLog
private var baseURL: URL { environment.endpoint }
-
+ private let cryptoUtil: CryptoUtil
+
init(
sdkVersion: String,
isLoggingEnabled: Bool = false,
environment: Environment = .production,
- _ session: NetworkSession = URLSession.shared
+ session: NetworkSession = URLSession.shared,
+ cryptoUtil: CryptoUtil = .liveValue
) {
#if os(tvOS)
self.clientVersion = "tvos-\(sdkVersion)"
@@ -39,16 +44,16 @@ internal final class UID2Client: Sendable {
: .disabled
self.environment = environment
self.session = session
+ self.cryptoUtil = cryptoUtil
}
-
+
func refreshIdentity(refreshToken: String, refreshResponseKey: String) async throws -> RefreshAPIPackage {
os_log("Refreshing identity", log: log, type: .debug)
let request = Request.refresh(token: refreshToken)
- let (data, statusCode) = try await execute(request)
+ let (data, response) = try await execute(request)
+ let statusCode = response.statusCode
+ let decoder = JSONDecoder.apiDecoder()
- let decoder = JSONDecoder()
- decoder.keyDecodingStrategy = .convertFromSnakeCase
-
// Only Decrypt If HTTP Status is 200 (Success or Opt Out)
if statusCode != 200 {
os_log("Client details failure: %d", log: log, type: .error, statusCode)
@@ -62,7 +67,8 @@ internal final class UID2Client: Sendable {
// Decrypt Data Envelop
// https://github.com/UnifiedID2/uid2docs/blob/main/api/v2/encryption-decryption.md
- guard let payloadData = DataEnvelope.decrypt(refreshResponseKey, data, true) else {
+ guard let key = Data(base64Encoded: refreshResponseKey).map(SymmetricKey.init(data: )),
+ let payloadData = DataEnvelope.decrypt(data, key: key) else {
os_log("Error decrypting response from client details", log: log, type: .error)
throw UID2Error.decryptPayloadData
}
@@ -77,6 +83,59 @@ internal final class UID2Client: Sendable {
return refreshAPIPackage
}
+ func generateIdentity(
+ _ identity: IdentityType,
+ subscriptionID: String,
+ serverPublicKey: String,
+ appName: String
+ ) async throws -> RefreshAPIPackage {
+ // Parse server key and generate our keys
+ let (symmetricKey, publicKey) = try cryptoUtil.parseKey(serverPublicKey)
+ let payload = ClientGeneratePayload(identity)
+ let authenticatedDataPayload = AuthenticatedData(appName: appName)
+
+ // Encrypt Data Envelope
+ let encoder = JSONEncoder.apiEncoder()
+ let payloadData = try encoder.encode(payload)
+
+ let authenticatedData = try encoder.encode(authenticatedDataPayload)
+ let sealedBox = try cryptoUtil.encrypt(
+ payloadData,
+ symmetricKey,
+ authenticatedData
+ )
+
+ let request = try Request.clientGenerate(
+ payload: sealedBox.ciphertext + sealedBox.tag,
+ initializationVector: Data(sealedBox.nonce),
+ publicKey: publicKey,
+ subscriptionID: subscriptionID,
+ timestamp: authenticatedDataPayload.timestamp,
+ appName: authenticatedDataPayload.appName
+ )
+ let (data, response) = try await execute(request)
+ let decoder = JSONDecoder.apiDecoder()
+ guard response.statusCode == 200 else {
+ do {
+ let tokenResponse = try decoder.decode(RefreshTokenResponse.self, from: data)
+ throw UID2Error.refreshTokenServer(status: tokenResponse.status, message: tokenResponse.message)
+ } catch {
+ throw UID2Error.refreshTokenServerDecoding(httpStatus: response.statusCode, message: error.localizedDescription)
+ }
+ }
+ guard let decryptedData = DataEnvelope.decrypt(data, key: symmetricKey) else {
+ throw UID2Error.decryptPayloadData
+ }
+
+ guard
+ let tokenResponse = try? decoder.decode(RefreshTokenResponse.self, from: decryptedData),
+ let refreshAPIPackage = tokenResponse.toRefreshAPIPackage() else {
+ throw UID2Error.refreshResponseToRefreshAPIPackage
+ }
+
+ return refreshAPIPackage
+ }
+
// MARK: - Request Execution
internal func urlRequest(
@@ -99,10 +158,32 @@ internal final class UID2Client: Sendable {
return urlRequest
}
- private func execute(_ request: Request) async throws -> (Data, Int) {
+ private func execute(_ request: Request) async throws -> (Data, HTTPURLResponse) {
let urlRequest = urlRequest(
request
)
return try await session.loadData(for: urlRequest)
}
}
+
+struct AuthenticatedData {
+ var timestamp: Int
+ var appName: String
+}
+
+extension AuthenticatedData {
+ init(date: Date = .init(), appName: String) {
+ self.init(
+ timestamp: Int(date.timeIntervalSince1970) * 1000,
+ appName: appName
+ )
+ }
+}
+
+extension AuthenticatedData: Encodable {
+ func encode(to encoder: any Encoder) throws {
+ var container = encoder.unkeyedContainer()
+ try container.encode(timestamp)
+ try container.encode(appName)
+ }
+}
diff --git a/Sources/UID2/UID2Error.swift b/Sources/UID2/UID2Error.swift
index 70bb4b6..2b642c7 100644
--- a/Sources/UID2/UID2Error.swift
+++ b/Sources/UID2/UID2Error.swift
@@ -14,10 +14,7 @@ enum UID2Error: Error {
/// Unable to decrypt Payload Data
case decryptPayloadData
- /// URLSession call did not return an HTTPURLResponse
- case httpURLResponse
-
- /// Server retunred a non HTTP 200 response
+ /// Server returned a non HTTP 200 response
case refreshTokenServer(status: RefreshTokenResponse.Status, message: String?)
/// Error parsing data / response from server
@@ -35,4 +32,6 @@ enum UID2Error: Error {
/// Unable to generate an UID2 Server
case urlGeneration
+ /// Invalid configuration
+ case configuration(message: String?)
}
diff --git a/Sources/UID2/UID2Manager.swift b/Sources/UID2/UID2Manager.swift
index 4a750f1..dd40660 100644
--- a/Sources/UID2/UID2Manager.swift
+++ b/Sources/UID2/UID2Manager.swift
@@ -149,7 +149,38 @@ public final actor UID2Manager {
public func setAutomaticRefreshEnabled(_ enable: Bool) {
self.automaticRefreshEnabled = enable
}
-
+
+ /// Generates a new identity.
+ ///
+ /// Once set, assuming it's valid, it will be monitored so that we automatically refresh the token(s) when required.
+ /// This will also be persisted locally, so that when the application re-launches, we reload this Identity.
+ /// - Parameters:
+ /// - identity: The DII or hash to create an identity token from
+ /// - subscriptionID: The subscription id that was obtained when configuring your account.
+ /// - serverPublicKey: The public key that was obtained when configuring your account.
+ /// - appName: The app's identifier. If `nil`, defaults to `Bundle.main.bundleIdentifier` which is appropriate in most cases.
+ public func generateIdentity(
+ _ identity: IdentityType,
+ subscriptionID: String,
+ serverPublicKey: String,
+ appName: String? = nil
+ ) async throws {
+ assert((appName ?? Bundle.main.bundleIdentifier) != nil, "An appName must be provided or a main bundleIdentifier set")
+ guard let appName = appName ?? Bundle.main.bundleIdentifier else {
+ throw UID2Error.configuration(message: "An appName must be provided or a main bundleIdentifier set")
+ }
+ let apiResponse = try await uid2Client.generateIdentity(
+ identity,
+ subscriptionID: subscriptionID,
+ serverPublicKey: serverPublicKey,
+ appName: appName
+ )
+ refreshJob?.cancel()
+ refreshJob = nil
+
+ await self.validateAndSetIdentity(identity: apiResponse.identity, status: apiResponse.status, statusText: apiResponse.message)
+ }
+
// MARK: - Internal Identity Lifecycle
private func setIdentityPackage(_ identity: IdentityPackage) async {
diff --git a/Tests/UID2Tests/AuthenticatedDataTests.swift b/Tests/UID2Tests/AuthenticatedDataTests.swift
new file mode 100644
index 0000000..571a96b
--- /dev/null
+++ b/Tests/UID2Tests/AuthenticatedDataTests.swift
@@ -0,0 +1,23 @@
+//
+// AuthenticatedDataTests.swift
+//
+//
+// Created by Dave Snabel-Caunt on 10/04/2024.
+//
+
+@testable import UID2
+import XCTest
+
+final class AuthenticatedDataTests: XCTestCase {
+
+ func testEncoding() throws {
+ let authenticatedData = AuthenticatedData(timestamp: 12345, appName: "com.uid2.test")
+ let jsonData = try JSONEncoder.apiEncoder().encode(authenticatedData)
+ XCTAssertEqual(
+ String(data: jsonData, encoding: .utf8),
+ """
+ [12345,"com.uid2.test"]
+ """
+ )
+ }
+}
diff --git a/Tests/UID2Tests/ClientGeneratePayloadTests.swift b/Tests/UID2Tests/ClientGeneratePayloadTests.swift
new file mode 100644
index 0000000..9b05fca
--- /dev/null
+++ b/Tests/UID2Tests/ClientGeneratePayloadTests.swift
@@ -0,0 +1,101 @@
+//
+// ClientGeneratePayloadTests.swift
+//
+//
+// Created by Dave Snabel-Caunt on 10/04/2024.
+//
+
+@testable import UID2
+import XCTest
+
+final class ClientGeneratePayloadTests: XCTestCase {
+
+ func testEmailPayload() throws {
+ let email = try XCTUnwrap(IdentityType.NormalizedEmail(string: "myemail@example.com"))
+ try assertPayloadJSON(
+ .email(email),
+ """
+ {
+ "email_hash" : "FsGNM28LJQ8OLZB0Us65ZYp07NrovJSGTCMSKnLMJ6U=",
+ "optout_check" : 1
+ }
+ """
+ )
+ }
+
+ func testEmailHashPayload() throws {
+ try assertPayloadJSON(
+ .emailHash("im-a-hash"),
+ """
+ {
+ "email_hash" : "im-a-hash",
+ "optout_check" : 1
+ }
+ """
+ )
+ }
+
+ func testPhonePayload() throws {
+ let phone = try XCTUnwrap(IdentityType.NormalizedPhone(normalized: "+12345678901"))
+ try assertPayloadJSON(
+ .phone(phone),
+ """
+ {
+ "optout_check" : 1,
+ "phone_hash" : "EObwtHBUqDNZR33LNSMdtt5cafsYFuGmuY4ZLenlue4="
+ }
+ """
+ )
+ }
+
+ func testPhoneHashPayload() throws {
+ try assertPayloadJSON(
+ .phoneHash("phone-hash"),
+ """
+ {
+ "optout_check" : 1,
+ "phone_hash" : "phone-hash"
+ }
+ """
+ )
+ }
+
+ func testEmailHashing() throws {
+ try [
+ // Documentation examples
+ ("myemail@example.com", "FsGNM28LJQ8OLZB0Us65ZYp07NrovJSGTCMSKnLMJ6U=", #line),
+ ("my.email@example.com", "4itTvG+HEnTzpiqzejyu1yFPwU1nYhWpaiQvz62hyB8=", #line),
+ ("janesaoirse@example.com", "1mcOepIAfxtf94Xx/IHlOqbT170GvfXEc83HKGwoS20=", #line),
+ ("jane.saoirse@example.com", "sZZDLHuYmiypHIN5mVfFFdpT5sE6vyC3j+qU8RfpC/g=", #line),
+ ("janesaoirse+work@example.com", "KKruSBUjDNO069iMUVImVQZm6RrAGZKeOtrD9mwogYA=", #line),
+ ("janesaoirse@gmail.com", "ku4mBX7Z3qJTXWyLFB1INzkyR2WZGW4ANSJUiW21iI8=", #line),
+ ].forEach { (email: String, expected: String, line: UInt) in
+ let normalized = try XCTUnwrap(IdentityType.NormalizedEmail(string: email), line: line)
+ XCTAssertEqual(expected, ClientGeneratePayload(.email(normalized)).value, line: line)
+ }
+ }
+
+ func testPhoneHashing() throws {
+ try [
+ ("+11234567890", "H6a42YbZuc0BvzaVGBUVi73p9SDAVnyDXf40eD0KQjE=", #line),
+ ("+6512345678", "xn2K5iZn+pV1H0nXXILY8ggcGt9dClVnIX13SXVVpZ8=", #line),
+ ("+61212345678", "J24LFuwzgT5ElsjMwcqE40S8VtUoWXFCZgkq+PDSD+c=", #line),
+ ].forEach { (phone: String, expected: String, line: UInt) in
+ let normalized = try XCTUnwrap(IdentityType.NormalizedPhone(normalized: phone), line: line)
+ XCTAssertEqual(expected, ClientGeneratePayload(.phone(normalized)).value, line: line)
+ }
+ }
+
+ private func assertPayloadJSON(_ identityType: IdentityType, _ json: String, file: StaticString = #filePath, line: UInt = #line) throws {
+ let payload = ClientGeneratePayload(identityType)
+ let encoder = JSONEncoder.apiEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ let data = try encoder.encode(payload)
+ XCTAssertEqual(
+ String(data: data, encoding: .utf8),
+ json,
+ file: file,
+ line: line
+ )
+ }
+}
diff --git a/Tests/UID2Tests/ClientGenerateRequestBodyTests.swift b/Tests/UID2Tests/ClientGenerateRequestBodyTests.swift
new file mode 100644
index 0000000..81e7ffe
--- /dev/null
+++ b/Tests/UID2Tests/ClientGenerateRequestBodyTests.swift
@@ -0,0 +1,50 @@
+//
+// ClientGenerateRequestBodyTests.swift
+//
+//
+// Created by Dave Snabel-Caunt on 10/04/2024.
+//
+
+import CryptoKit
+@testable import UID2
+import XCTest
+
+final class ClientGenerateRequestBodyTests: XCTestCase {
+
+ func testEmailPayload() throws {
+ let body = ClientGenerateRequestBody(
+ payload: "payload",
+ initializationVector: "initializationVector",
+ publicKey: "publicKey",
+ subscriptionID: "subscriptionID",
+ timestamp: 90210,
+ appName: "com.my.app"
+ )
+
+ try assertPayloadJSON(
+ body,
+ """
+ {
+ "app_name" : "com.my.app",
+ "iv" : "initializationVector",
+ "payload" : "payload",
+ "public_key" : "publicKey",
+ "subscription_id" : "subscriptionID",
+ "timestamp" : 90210
+ }
+ """
+ )
+ }
+
+ private func assertPayloadJSON(_ payload: any Encodable, _ json: String, file: StaticString = #filePath, line: UInt = #line) throws {
+ let encoder = JSONEncoder.apiEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ let data = try encoder.encode(payload)
+ XCTAssertEqual(
+ String(data: data, encoding: .utf8),
+ json,
+ file: file,
+ line: line
+ )
+ }
+}
diff --git a/Tests/UID2Tests/IdentityPackageTests.swift b/Tests/UID2Tests/IdentityPackageTests.swift
index f2947db..90a57d1 100644
--- a/Tests/UID2Tests/IdentityPackageTests.swift
+++ b/Tests/UID2Tests/IdentityPackageTests.swift
@@ -11,26 +11,12 @@ import XCTest
final class IdentityPackageTests: XCTestCase {
- private let decoder: JSONDecoder = {
- let decoder = JSONDecoder()
- decoder.keyDecodingStrategy = .convertFromSnakeCase
- return decoder
- }()
-
- private let encoder: JSONEncoder = {
- let encoder = JSONEncoder()
- encoder.keyEncodingStrategy = .convertToSnakeCase
- return encoder
- }()
-
+ private let decoder = JSONDecoder.apiDecoder()
+
+ private let encoder = JSONEncoder.apiEncoder()
+
func testRoundTripEncodingDecoding() throws {
-
- let data = try DataLoader.load(fileName: "uididentity", fileExtension: "json")
-
- guard let uid2Identity = UID2Identity.fromData(data) else {
- XCTFail("Unable to load UID2Identity data")
- return
- }
+ let uid2Identity = try FixtureLoader.decode(UID2Identity.self, fixture: "uididentity")
let identityPackage = IdentityPackage(valid: true, errorMessage: nil, identity: uid2Identity, status: .established)
diff --git a/Tests/UID2Tests/IdentityTypeTests.swift b/Tests/UID2Tests/IdentityTypeTests.swift
new file mode 100644
index 0000000..37486e4
--- /dev/null
+++ b/Tests/UID2Tests/IdentityTypeTests.swift
@@ -0,0 +1,127 @@
+//
+// IdentityTypeTests.swift
+//
+//
+// Created by Dave Snabel-Caunt on 10/04/2024.
+//
+
+import UID2
+import XCTest
+
+final class IdentityTypeTests: XCTestCase {
+
+ func testEmailNormalization() {
+ [
+ // Identity
+ ("myemail@example.com", "myemail@example.com", #line),
+
+ // Documentation examples
+ ("MyEmail@example.com", "myemail@example.com", #line),
+ ("MYEMAIL@example.com", "myemail@example.com", #line),
+
+ ("My.Email@example.com", "my.email@example.com", #line),
+
+ ("JANESAOIRSE@example.com", "janesaoirse@example.com", #line),
+ ("JaneSaoirse@example.com", "janesaoirse@example.com", #line),
+
+ ("jane.saoirse@example.com", "jane.saoirse@example.com", #line),
+ ("Jane.Saoirse@example.com", "jane.saoirse@example.com", #line),
+
+ ("JaneSaoirse+Work@example.com", "janesaoirse+work@example.com", #line),
+
+ ("JANE.SAOIRSE@gmail.com", "janesaoirse@gmail.com", #line),
+ ("Jane.Saoirse@gmail.com", "janesaoirse@gmail.com", #line),
+ ("JaneSaoirse+Work@gmail.com", "janesaoirse@gmail.com", #line),
+
+ // Edge cases
+ ("JaneSaoirse+@gmail.com", "janesaoirse@gmail.com", #line),
+ ("JaneSaoirse++@gmail.com", "janesaoirse@gmail.com", #line),
+ ("JaneSaoirse+Work+more.work@gmail.com", "janesaoirse@gmail.com", #line),
+ ("Jane.Saoirse+Work@gmail.com", "janesaoirse@gmail.com", #line),
+
+ // Java tests
+ ("TEst.TEST@Test.com ", "test.test@test.com", #line),
+ ("test.test@test.com", "test.test@test.com", #line),
+ ("test.test@gmail.com", "testtest@gmail.com", #line),
+ ("test+test@test.com", "test+test@test.com", #line),
+ ("+test@test.com", "+test@test.com", #line),
+ ("test+test@gmail.com", "test@gmail.com", #line),
+ ("testtest@test.com", "testtest@test.com", #line),
+ (" testtest@test.com", "testtest@test.com", #line),
+ ("testtest@test.com ", "testtest@test.com", #line),
+ (" testtest@test.com ", "testtest@test.com", #line),
+ (" testtest@test.com ", "testtest@test.com", #line),
+ (" test.test@gmail.com", "testtest@gmail.com", #line),
+ ("test.test@gmail.com ", "testtest@gmail.com", #line),
+ (" test.test@gmail.com ", "testtest@gmail.com", #line),
+ (" test.test@gmail.com ", "testtest@gmail.com", #line),
+ ("TEstTEst@gmail.com ", "testtest@gmail.com", #line),
+ ("TEstTEst@GMail.Com ", "testtest@gmail.com", #line),
+ (" TEstTEst@GMail.Com ", "testtest@gmail.com", #line),
+ ("TEstTEst@GMail.Com", "testtest@gmail.com", #line),
+ ("TEst.TEst@GMail.Com", "testtest@gmail.com", #line),
+ ("TEst.TEst+123@GMail.Com", "testtest@gmail.com", #line),
+ ("TEst.TEST@Test.com ", "test.test@test.com", #line),
+ ("TEst.TEST@Test.com ", "test.test@test.com", #line),
+ ].forEach { (email: String, expected: String, line: UInt) in
+ XCTAssertEqual(expected, IdentityType.NormalizedEmail(string: email)?.value, line: line)
+ }
+
+ [
+ ("", #line),
+ (" @", #line),
+ ("@", #line),
+ ("a@", #line),
+ ("@b", #line),
+ ("@b.com", #line),
+ ("+", #line),
+ (" ", #line),
+ ("+@gmail.com", #line),
+ (".+@gmail.com", #line),
+ ("a@ba@z.com", #line),
+ ].forEach { (email: String, line: UInt) in
+ XCTAssertNil(IdentityType.NormalizedEmail(string: email), line: line)
+ }
+ }
+
+ func testPhoneNormalization() {
+ [
+ ("", #line),
+ ("asdaksjdakfj", #line),
+ ("DH5qQFhi5ALrdqcPiib8cy0Hwykx6frpqxWCkR0uijs", #line),
+ ("QFhi5ALrdqcPiib8cy0Hwykx6frpqxWCkR0uijs", #line),
+ ("06a418f467a14e1631a317b107548a1039d26f12ea45301ab14e7684b36ede58", #line),
+ ("0C7E6A405862E402EB76A70F8A26FC732D07C32931E9FAE9AB1582911D2E8A3B", #line),
+ ("+", #line),
+ ("12345678", #line),
+ ("123456789", #line),
+ ("1234567890", #line),
+ ("+12345678", #line),
+ ("+123456789", #line),
+ ("+ 12345678", #line),
+ ("+ 123456789", #line),
+ ("+ 1234 5678", #line),
+ ("+ 1234 56789", #line),
+ ("+1234567890123456", #line),
+ ("+1234567890A", #line),
+ ("+1234567890 ", #line),
+ ("+1234567890+", #line),
+ ("+12345+67890", #line),
+ ("555-555-5555", #line),
+ ("(555) 555-5555", #line),
+ ].forEach { (phone: String, line: UInt) in
+ XCTAssertNil(IdentityType.NormalizedPhone(normalized: phone), line: line)
+ }
+
+ [
+ ("+1234567890", #line),
+ ("+12345678901", #line),
+ ("+123456789012", #line),
+ ("+1234567890123", #line),
+ ("+12345678901234", #line),
+ ("+123456789012345", #line),
+ ].forEach { (phone: String, line: UInt) in
+ XCTAssertNotNil(IdentityType.NormalizedPhone(normalized: phone), line: line)
+ }
+ }
+}
diff --git a/Tests/UID2Tests/RefreshTokenAPITests.swift b/Tests/UID2Tests/RefreshTokenAPITests.swift
index 9f6f088..a9b96bb 100644
--- a/Tests/UID2Tests/RefreshTokenAPITests.swift
+++ b/Tests/UID2Tests/RefreshTokenAPITests.swift
@@ -14,13 +14,8 @@ final class RefreshTokenAPITests: XCTestCase {
/// uid2-iOS-sdk@test.com
func testRefreshTokenSuccess() async throws {
- let decoder = JSONDecoder()
- decoder.keyDecodingStrategy = .convertFromSnakeCase
-
// Load Generate Token
- let generateData = try DataLoader.load(fileName: "generate-token-200-success", fileExtension: "json")
- print("generateData = " + String(decoding: generateData, as: UTF8.self))
- let generateTokenResponse = try decoder.decode(RefreshTokenResponse.self, from: generateData)
+ let generateTokenResponse = try FixtureLoader.decode(RefreshTokenResponse.self, fixture: "generate-token-200-success")
guard let generateToken = generateTokenResponse.toUID2Identity() else {
throw "Unable to create generateToken"
}
@@ -28,7 +23,7 @@ final class RefreshTokenAPITests: XCTestCase {
// Load UID2Client Mocked
let client = UID2Client(
sdkVersion: "TEST",
- MockNetworkSession("refresh-token-200-success-encrypted", "txt")
+ session: MockNetworkSession("refresh-token-200-success-encrypted", "txt")
)
// Call RefreshToken using refreshToken and refreshResponseKey from Step 1 to decrypt
@@ -36,12 +31,10 @@ final class RefreshTokenAPITests: XCTestCase {
refreshResponseKey: generateToken.refreshResponseKey)
// Load Local RefreshToken from JSON
- let localRefreshData = try DataLoader.load(fileName: "refresh-token-200-success-decrypted", fileExtension: "json")
- let localTokenResponse = try decoder.decode(RefreshTokenResponse.self, from: localRefreshData)
+ let localTokenResponse = try FixtureLoader.decode(RefreshTokenResponse.self, fixture: "refresh-token-200-success-decrypted")
guard let localRefreshToken = localTokenResponse.toUID2Identity() else {
throw "Unable to create localRefreshToken"
}
-
XCTAssertEqual(refreshToken.identity, localRefreshToken)
}
@@ -50,13 +43,8 @@ final class RefreshTokenAPITests: XCTestCase {
/// https://github.com/IABTechLab/uid2docs/blob/main/api/v2/endpoints/post-token-refresh.md#testing-notes
func testRefreshTokenOptOut() async throws {
- let decoder = JSONDecoder()
- decoder.keyDecodingStrategy = .convertFromSnakeCase
-
// Load Generate Token
- let generateData = try DataLoader.load(fileName: "generate-token-200-optout", fileExtension: "json")
- print("generateData = " + String(decoding: generateData, as: UTF8.self))
- let generateTokenResponse = try decoder.decode(RefreshTokenResponse.self, from: generateData)
+ let generateTokenResponse = try FixtureLoader.decode(RefreshTokenResponse.self, fixture: "generate-token-200-optout")
guard let generateToken = generateTokenResponse.toUID2Identity() else {
throw "Unable to create generateToken"
}
@@ -64,7 +52,7 @@ final class RefreshTokenAPITests: XCTestCase {
// Load UID2Client Mocked
let client = UID2Client(
sdkVersion: "TEST",
- MockNetworkSession("refresh-token-200-optout-encrypted", "txt")
+ session: MockNetworkSession("refresh-token-200-optout-encrypted", "txt")
)
// Call RefreshToken using refreshToken and refreshResponseKey from Step 1 to decrypt
@@ -72,8 +60,7 @@ final class RefreshTokenAPITests: XCTestCase {
refreshResponseKey: generateToken.refreshResponseKey)
// Load Local RefreshToken from JSON
- let localRefreshData = try DataLoader.load(fileName: "refresh-token-200-optout-decrypted", fileExtension: "json")
- let localTokenResponse = try decoder.decode(RefreshTokenResponse.self, from: localRefreshData)
+ let localTokenResponse = try FixtureLoader.decode(RefreshTokenResponse.self, fixture: "refresh-token-200-optout-decrypted")
let localResponsePackage = localTokenResponse.toRefreshAPIPackage()
XCTAssertEqual(refreshToken.status, localResponsePackage?.status)
@@ -87,7 +74,7 @@ final class RefreshTokenAPITests: XCTestCase {
// Load UID2Client Mocked
let client = UID2Client(
sdkVersion: "TEST",
- MockNetworkSession("refresh-token-400-client-error", "json", 400)
+ session: MockNetworkSession("refresh-token-400-client-error", "json", 400)
)
// Call RefreshToken using refreshToken and refreshResponseKey from Step 1 to decrypt
@@ -116,7 +103,7 @@ final class RefreshTokenAPITests: XCTestCase {
// Load UID2Client Mocked
let client = UID2Client(
sdkVersion: "TEST",
- MockNetworkSession("refresh-token-400-invalid-token", "json", 400)
+ session: MockNetworkSession("refresh-token-400-invalid-token", "json", 400)
)
// Call RefreshToken using refreshToken and refreshResponseKey from Step 1 to decrypt
@@ -145,7 +132,7 @@ final class RefreshTokenAPITests: XCTestCase {
// Load UID2Client Mocked
let client = UID2Client(
sdkVersion: "TEST",
- MockNetworkSession("refresh-token-401-unauthorized", "json", 401)
+ session: MockNetworkSession("refresh-token-401-unauthorized", "json", 401)
)
// Call RefreshToken using refreshToken and refreshResponseKey from Step 1 to decrypt
diff --git a/Tests/UID2Tests/TestExtensions/DataLoader.swift b/Tests/UID2Tests/TestExtensions/DataLoader.swift
deleted file mode 100644
index c6b5fd1..0000000
--- a/Tests/UID2Tests/TestExtensions/DataLoader.swift
+++ /dev/null
@@ -1,21 +0,0 @@
-//
-// DataLoader.swift
-//
-//
-// Created by Brad Leege on 2/1/23.
-//
-
-import Foundation
-
-final class DataLoader {
-
- static func load(fileName: String, fileExtension: String, _ inDirectory: String = "TestData") throws -> Data {
- guard let bundlePath = Bundle.module.path(forResource: fileName, ofType: fileExtension, inDirectory: inDirectory),
- let stringData = try String(contentsOfFile: bundlePath).data(using: .utf8) else {
- throw "Could not load data from file."
- }
-
- return stringData
- }
-
-}
diff --git a/Tests/UID2Tests/TestExtensions/FixtureLoader.swift b/Tests/UID2Tests/TestExtensions/FixtureLoader.swift
new file mode 100644
index 0000000..61af92e
--- /dev/null
+++ b/Tests/UID2Tests/TestExtensions/FixtureLoader.swift
@@ -0,0 +1,45 @@
+//
+// FixtureLoader.swift
+//
+//
+// Created by Dave Snabel-Caunt on 18/04/2024.
+//
+
+import Foundation
+@testable import UID2
+
+final class FixtureLoader {
+ enum Error: Swift.Error {
+ case missingFixture(String)
+ }
+
+ /// Read `Data` from a Fixture.
+ static func data(
+ fixture: String,
+ withExtension fileExtension: String = "json",
+ subdirectory: String = "TestData"
+ ) throws -> Data {
+ guard let fixtureURL = Bundle.module.url(
+ forResource: fixture,
+ withExtension: fileExtension,
+ subdirectory: subdirectory
+ ) else {
+ throw Error.missingFixture("\(subdirectory)/\(fixture).\(fileExtension)")
+ }
+ return try Data(contentsOf: fixtureURL)
+ }
+
+ /// Decode a `Decodable` from a Fixture.
+ /// Expects the fixture to use snake_case key encoding.
+ static func decode(
+ _ type: T.Type,
+ fixture: String,
+ withExtension fileExtension: String = "json",
+ subdirectory: String = "TestData"
+ ) throws -> T where T : Decodable {
+ let data = try data(fixture: fixture, withExtension: fileExtension, subdirectory: subdirectory)
+ let decoder = JSONDecoder.apiDecoder()
+ return try decoder.decode(type, from: data)
+ }
+}
+
diff --git a/Tests/UID2Tests/TestExtensions/HTTPStub.swift b/Tests/UID2Tests/TestExtensions/HTTPStub.swift
new file mode 100644
index 0000000..d866546
--- /dev/null
+++ b/Tests/UID2Tests/TestExtensions/HTTPStub.swift
@@ -0,0 +1,87 @@
+//
+// HTTPStub.swift
+//
+//
+// Created by Dave Snabel-Caunt on 18/04/2024.
+//
+
+import Foundation
+
+internal final class HTTPStub {
+ static let shared: HTTPStub = {
+ let stub = HTTPStub()
+ URLProtocol.registerClass(HTTPStubProtocol.self)
+ return stub
+ }()
+
+ private init() {}
+
+ // Provides stubs in response to requests
+ var stubs: ((URLRequest) -> Result<(data: Data, response: HTTPURLResponse), Error>)!
+
+ // Stub for the current request
+ private var stub: Result<(data: Data, response: HTTPURLResponse), Error>!
+
+ private class HTTPStubProtocol: URLProtocol {
+ private var isCancelled = false
+
+ override class func canInit(with: URLRequest) -> Bool {
+ true
+ }
+
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest {
+ // Set the active stub
+ HTTPStub.shared.stub = HTTPStub.shared.stubs(request)
+ return request
+ }
+
+ override func startLoading() {
+ let stub = HTTPStub.shared.stub!
+
+ let queue = DispatchQueue.global(qos: .default)
+ queue.asyncAfter(deadline: .now() + 0.01) {
+ guard !self.isCancelled, let client = self.client else {
+ return
+ }
+
+ switch stub {
+ case .success(let (data, response)):
+ client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client.urlProtocol(self, didLoad: data)
+ case .failure(let error):
+ client.urlProtocol(self, didFailWithError: error)
+ }
+
+ client.urlProtocolDidFinishLoading(self)
+ }
+ }
+
+ override func stopLoading() {
+ isCancelled = true
+ }
+ }
+}
+
+internal extension HTTPURLResponse {
+ convenience init(
+ url: URL,
+ statusCode: Int = 200,
+ httpVersion: String = "1.1",
+ headerFields: [String:String]? = nil
+ ) {
+ self.init(
+ url: url,
+ statusCode: statusCode,
+ httpVersion: "1.1",
+ headerFields: nil
+ )!
+ }
+}
+
+struct MissingFixtureError: Error, LocalizedError {
+ var url: URL
+
+ var errorDescription: String? {
+ "No stub registered for path \(url.path)"
+ }
+}
diff --git a/Tests/UID2Tests/TestExtensions/MockNetworkSession.swift b/Tests/UID2Tests/TestExtensions/MockNetworkSession.swift
index b9b8b0c..ddafaaa 100644
--- a/Tests/UID2Tests/TestExtensions/MockNetworkSession.swift
+++ b/Tests/UID2Tests/TestExtensions/MockNetworkSession.swift
@@ -20,9 +20,10 @@ final class MockNetworkSession: NetworkSession {
self.responseCode = responseCode
}
- func loadData(for request: URLRequest) throws -> (Data, Int) {
- let jsonData = try DataLoader.load(fileName: fileName, fileExtension: fileExtension)
- return (jsonData, responseCode)
+ func loadData(for request: URLRequest) throws -> (Data, HTTPURLResponse) {
+ let jsonData = try FixtureLoader.data(fixture: fileName, withExtension: fileExtension)
+ let response = HTTPURLResponse(url: request.url!, statusCode: responseCode, httpVersion: nil, headerFields: nil)!
+ return (jsonData, response)
}
}
diff --git a/Tests/UID2Tests/TestExtensions/XCTest+Extensions.swift b/Tests/UID2Tests/TestExtensions/XCTest+Extensions.swift
new file mode 100644
index 0000000..83c5a1d
--- /dev/null
+++ b/Tests/UID2Tests/TestExtensions/XCTest+Extensions.swift
@@ -0,0 +1,24 @@
+//
+// XCTest+Extensions.swift
+//
+//
+// Created by Dave Snabel-Caunt on 18/04/2024.
+//
+
+import XCTest
+
+/// `XCTAssertThrowsError` doesn't support async expressions.
+internal func assertThrowsError(
+ _ expression: @escaping @autoclosure () async throws -> T,
+ _ message: @autoclosure () -> String = "",
+ file: StaticString = #filePath,
+ line: UInt = #line,
+ _ errorHandler: (_ error: Error) -> Void = { _ in }
+) async {
+ // Use `Result.get` to rethrow inside `XCTAssertThrowsError` after the asynchronous `expression` is complete.
+ let result = await Task {
+ try await expression()
+ }.result
+ XCTAssertThrowsError(try result.get(), message(), file: file, line: line, errorHandler)
+}
+
diff --git a/Tests/UID2Tests/UID2ClientTests.swift b/Tests/UID2Tests/UID2ClientTests.swift
index 6b886c9..a7e3648 100644
--- a/Tests/UID2Tests/UID2ClientTests.swift
+++ b/Tests/UID2Tests/UID2ClientTests.swift
@@ -5,11 +5,12 @@
// Created by Dave Snabel-Caunt on 25/04/2024.
//
+import CryptoKit
+import Foundation
@testable import UID2
import XCTest
final class UID2ClientTests: XCTestCase {
-
func testDefaultEnvironment() async throws {
let client = UID2Client(
sdkVersion: "1.0"
@@ -44,4 +45,199 @@ final class UID2ClientTests: XCTestCase {
URL(string: "http://localhost:8080/path")!
)
}
+
+ func testClientVersionHeader() throws {
+ let client = UID2Client(
+ sdkVersion: "1.2.3"
+ )
+
+ let request = client.urlRequest(Request(path: "/test"))
+#if os(tvOS)
+ XCTAssertEqual(
+ request.allHTTPHeaderFields,
+ [
+ "X-UID2-Client-Version": "tvos-1.2.3"
+ ]
+ )
+#else
+ XCTAssertEqual(
+ request.allHTTPHeaderFields,
+ [
+ "X-UID2-Client-Version": "ios-1.2.3"
+ ]
+ )
+#endif
+ }
+
+ // MARK: Client-side token generation tests
+
+ // swiftlint:disable:next line_length
+ private let serverPublicKeyString = "UID2-X-I-MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKAbPfOz7u25g1fL6riU7p2eeqhjmpALPeYoyjvZmZ1xM2NM8UeOmDZmCIBnKyRZ97pz5bMCjrs38WM22O7LJuw=="
+
+ func testClientGenerateServerPublicKeyError() async throws {
+ let client = UID2Client(
+ sdkVersion: "1.0"
+ )
+
+ await assertThrowsError(
+ try await client.generateIdentity(
+ .emailHash("tMmiiTI7IaAcPpQPFQ65uMVCWH8av9jw4cwf/F5HVRQ="),
+ subscriptionID: "test",
+ serverPublicKey: "not-an-encoded-public-key",
+ appName: "com.example.app"
+ )
+ ) { error in
+ guard let error = error as? UID2Error,
+ case let .configuration(message: message) = error else {
+ XCTFail("Expected UID2Error.configuration, got \(error)")
+ return
+ }
+ XCTAssertEqual(message, "Invalid server key as base64")
+ }
+ }
+
+ func testClientGenerateServerDecodeError() async throws {
+ HTTPStub.shared.stubs = { request in
+ XCTAssertEqual(request.url?.path, "/v2/token/client-generate")
+ let data = Data("not-encrypted-data".utf8)
+ let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
+ return .success((data, response))
+ }
+ let client = UID2Client(
+ sdkVersion: "1.0"
+ )
+
+ await assertThrowsError(
+ try await client.generateIdentity(
+ .emailHash("tMmiiTI7IaAcPpQPFQ65uMVCWH8av9jw4cwf/F5HVRQ="),
+ subscriptionID: "test",
+ serverPublicKey: self.serverPublicKeyString,
+ appName: "com.example.app"
+ )
+ ) { error in
+ guard let error = error as? UID2Error,
+ case .decryptPayloadData = error else {
+ XCTFail("Expected UID2Error.decryptPayloadData, got \(error)")
+ return
+ }
+ }
+ }
+
+ func testClientGenerateServerDecodeErrorBase64() async throws {
+ HTTPStub.shared.stubs = { request in
+ XCTAssertEqual(request.url?.path, "/v2/token/client-generate")
+ let data = Data("not-encrypted-data".utf8).base64EncodedData()
+ let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
+ return .success((data, response))
+ }
+ let client = UID2Client(
+ sdkVersion: "1.0"
+ )
+
+ await assertThrowsError(
+ try await client.generateIdentity(
+ .emailHash("tMmiiTI7IaAcPpQPFQ65uMVCWH8av9jw4cwf/F5HVRQ="),
+ subscriptionID: "test",
+ serverPublicKey: self.serverPublicKeyString,
+ appName: "com.example.app"
+ )
+ ) { error in
+ guard let error = error as? UID2Error,
+ case .decryptPayloadData = error else {
+ XCTFail("Expected UID2Error.decryptPayloadData, got \(error)")
+ return
+ }
+ }
+ }
+
+ func testClientGenerateServerClientError() async throws {
+ HTTPStub.shared.stubs = { request in
+ XCTAssertEqual(request.url?.path, "/v2/token/client-generate")
+
+ let data = try! FixtureLoader.data(fixture: "refresh-token-400-client-error")
+
+ let response = HTTPURLResponse(url: request.url!, statusCode: 400, httpVersion: nil, headerFields: nil)!
+ return .success((data, response))
+ }
+ let client = UID2Client(
+ sdkVersion: "1.0"
+ )
+
+ await assertThrowsError(
+ try await client.generateIdentity(
+ .emailHash("tMmiiTI7IaAcPpQPFQ65uMVCWH8av9jw4cwf/F5HVRQ="),
+ subscriptionID: "test",
+ serverPublicKey: self.serverPublicKeyString,
+ appName: "com.example.app"
+ )
+ ) { error in
+ guard let error = error as? UID2Error,
+ case .refreshTokenServerDecoding = error else {
+ XCTFail("Expected UID2Error.refreshTokenServerDecoding, got \(error)")
+ return
+ }
+ }
+ }
+
+ func testClientGenerateSuccess() async throws {
+ // Symmetric key generated by the client
+ let symmetricKey = Atomic(nil)
+ 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 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)
+ }
+ )
+ )
+
+ let result = try await client.generateIdentity(
+ .emailHash("tMmiiTI7IaAcPpQPFQ65uMVCWH8av9jw4cwf/F5HVRQ="),
+ subscriptionID: "test",
+ serverPublicKey: serverPublicKeyString,
+ appName: "com.example.app"
+ )
+ XCTAssertNotNil(result.identity)
+ }
+}
+
+// Simple Atomic implementation for test usage
+private final class Atomic: @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
+ }
+ }
+
+ init(_ value: Value) {
+ _value = value
+ }
}
diff --git a/Tests/UID2Tests/UID2TokenTests.swift b/Tests/UID2Tests/UID2TokenTests.swift
index 6dcdb2d..52c13c5 100644
--- a/Tests/UID2Tests/UID2TokenTests.swift
+++ b/Tests/UID2Tests/UID2TokenTests.swift
@@ -5,14 +5,7 @@ import XCTest
final class UID2TokenTests: XCTestCase {
func testUID2TokenLoad() throws {
-
- let data = try DataLoader.load(fileName: "uididentity", fileExtension: "json")
-
- guard let uid2Identity = UID2Identity.fromData(data) else {
- XCTFail("Unable to load UID2Identity data")
- return
- }
-
+ let uid2Identity = try FixtureLoader.decode(UID2Identity.self, fixture: "uididentity")
XCTAssertEqual(
uid2Identity,
.init(