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(