diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index dacf0f0..b8f3068 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -19,8 +19,14 @@ jobs: - name: Lint code run: swiftlint lint --config .swiftlint.yml --reporter github-actions-logging - - name: Build - run: xcodebuild -scheme UID2 -sdk iphonesimulator16.2 -destination "OS=16.2,name=iPhone 14" + - name: Build for iOS + run: xcodebuild -scheme UID2 -destination "generic/platform=iOS" + + - name: Build for tvOS + 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" + + - name: Run unit tests on tvOS + run: xcodebuild test -scheme UID2Tests -sdk appletvsimulator16.1 -destination "OS=16.1,name=Apple TV" diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/UID2.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/UID2.xcscheme index 3ce251a..3792a46 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/UID2.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/UID2.xcscheme @@ -28,6 +28,16 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + (Data, Int) diff --git a/Sources/UID2/Networking/RefreshRequest.swift b/Sources/UID2/Networking/RefreshRequest.swift new file mode 100644 index 0000000..79b685a --- /dev/null +++ b/Sources/UID2/Networking/RefreshRequest.swift @@ -0,0 +1,23 @@ +// +// RefreshRequest.swift +// +// +// Created by Dave Snabel-Caunt on 24/04/2024. +// + +import Foundation + +extension Request { + static func refresh( + token: String + ) -> Request { + .init( + path: "/v2/token/refresh", + method: .post, + body: Data(token.utf8), + headers: [ + "Content-Type": "application/x-www-form-urlencoded" + ] + ) + } +} diff --git a/Sources/UID2/Networking/Request.swift b/Sources/UID2/Networking/Request.swift new file mode 100644 index 0000000..335dae7 --- /dev/null +++ b/Sources/UID2/Networking/Request.swift @@ -0,0 +1,35 @@ +// +// Request.swift +// +// +// Created by Dave Snabel-Caunt on 09/04/2024. +// + +import Foundation + +enum Method: String { + case get = "GET" + case post = "POST" +} + +struct Request { + var method: Method + var path: String + var queryItems: [URLQueryItem] + var body: Data? + var headers: [String: String] + + init( + path: String, + method: Method = .get, + queryItems: [URLQueryItem] = [], + body: Data? = nil, + headers: [String: String] = [:] + ) { + self.path = path + self.method = method + self.queryItems = queryItems + self.body = body + self.headers = headers + } +} diff --git a/Sources/UID2/UID2Client.swift b/Sources/UID2/UID2Client.swift index 24deb87..d141264 100644 --- a/Sources/UID2/UID2Client.swift +++ b/Sources/UID2/UID2Client.swift @@ -7,7 +7,6 @@ import Foundation -@available(iOS 13.0, *) internal final class UID2Client { private let uid2APIURL: String @@ -16,56 +15,75 @@ internal final class UID2Client { init(uid2APIURL: String, sdkVersion: String, _ session: NetworkSession = URLSession.shared) { self.uid2APIURL = uid2APIURL + #if os(tvOS) + self.clientVersion = "tvos-\(sdkVersion)" + #else self.clientVersion = "ios-\(sdkVersion)" + #endif self.session = session } func refreshIdentity(refreshToken: String, refreshResponseKey: String) async throws -> RefreshAPIPackage { - var components = URLComponents(string: uid2APIURL) - components?.path = "/v2/token/refresh" - - guard let urlPath = components?.url?.absoluteString, - let url = URL(string: urlPath) else { - throw UID2Error.urlGeneration - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.addValue(clientVersion, forHTTPHeaderField: "X-UID2-Client-Version") - request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpBody = refreshToken.data(using: .utf8) - - let dataResponse = try await session.loadData(for: request) - let data = dataResponse.0 - let statusCode = dataResponse.1 - - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - - // Only Decrypt If HTTP Status is 200 (Success or Opt Out) - if statusCode != 200 { - do { - let tokenResponse = try decoder.decode(RefreshTokenResponse.self, from: data) - throw UID2Error.refreshTokenServer(status: tokenResponse.status, message: tokenResponse.message) - } catch { - throw UID2Error.refreshTokenServerDecoding(httpStatus: statusCode, message: error.localizedDescription) - } - } - - // Decrypt Data Envelop - // https://github.com/UnifiedID2/uid2docs/blob/main/api/v2/encryption-decryption.md - guard let payloadData = DataEnvelope.decrypt(refreshResponseKey, data, true) else { - throw UID2Error.decryptPayloadData - } - - let tokenResponse = try decoder.decode(RefreshTokenResponse.self, from: payloadData) - - guard let refreshAPIPackage = tokenResponse.toRefreshAPIPackage() else { - throw UID2Error.refreshResponseToRefreshAPIPackage + let request = Request.refresh(token: refreshToken) + let (data, statusCode) = try await execute(request) + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + // Only Decrypt If HTTP Status is 200 (Success or Opt Out) + if statusCode != 200 { + do { + let tokenResponse = try decoder.decode(RefreshTokenResponse.self, from: data) + throw UID2Error.refreshTokenServer(status: tokenResponse.status, message: tokenResponse.message) + } catch { + throw UID2Error.refreshTokenServerDecoding(httpStatus: statusCode, message: error.localizedDescription) } - - return refreshAPIPackage } + // Decrypt Data Envelop + // https://github.com/UnifiedID2/uid2docs/blob/main/api/v2/encryption-decryption.md + guard let payloadData = DataEnvelope.decrypt(refreshResponseKey, data, true) else { + throw UID2Error.decryptPayloadData + } + + let tokenResponse = try decoder.decode(RefreshTokenResponse.self, from: payloadData) + + guard let refreshAPIPackage = tokenResponse.toRefreshAPIPackage() else { + throw UID2Error.refreshResponseToRefreshAPIPackage + } + + return refreshAPIPackage + } + + // MARK: - Request Execution + + internal func urlRequest( + _ request: Request, + baseURL: URL + ) -> URLRequest { + var urlComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: true)! + urlComponents.path = request.path + urlComponents.queryItems = request.queryItems.isEmpty ? nil : request.queryItems + + var urlRequest = URLRequest(url: urlComponents.url!) + urlRequest.httpMethod = request.method.rawValue + if request.method == .post { + urlRequest.httpBody = request.body + } + + request.headers.forEach { field, value in + urlRequest.addValue(value, forHTTPHeaderField: field) + } + urlRequest.addValue(clientVersion, forHTTPHeaderField: "X-UID2-Client-Version") + return urlRequest + } + + private func execute(_ request: Request) async throws -> (Data, Int) { + let urlRequest = urlRequest( + request, + baseURL: URL(string: uid2APIURL)! + ) + return try await session.loadData(for: urlRequest) + } } diff --git a/Sources/UID2/UID2Error.swift b/Sources/UID2/UID2Error.swift index 97ebdf2..70bb4b6 100644 --- a/Sources/UID2/UID2Error.swift +++ b/Sources/UID2/UID2Error.swift @@ -9,7 +9,6 @@ import Foundation /// UID2 Specifc Errors -@available(iOS 13.0, *) enum UID2Error: Error { /// Unable to decrypt Payload Data diff --git a/Sources/UID2/UID2Manager.swift b/Sources/UID2/UID2Manager.swift index bcddad5..e1e46b8 100644 --- a/Sources/UID2/UID2Manager.swift +++ b/Sources/UID2/UID2Manager.swift @@ -8,7 +8,6 @@ import Combine import Foundation -@available(iOS 13.0, *) public final actor UID2Manager { /// Singleton access point for UID2Manager diff --git a/Tests/UID2Tests/RefreshRequestTests.swift b/Tests/UID2Tests/RefreshRequestTests.swift new file mode 100644 index 0000000..4d1f105 --- /dev/null +++ b/Tests/UID2Tests/RefreshRequestTests.swift @@ -0,0 +1,45 @@ +// +// RefreshRequestTests.swift +// +// +// Created by Dave Snabel-Caunt on 24/04/2024. +// + +import XCTest +@testable import UID2 + +final class RefreshRequestTests: XCTestCase { + + func testRequest() async throws { + let request = Request.refresh(token: "im-a-refresh-token") + let client = UID2Client( + uid2APIURL: "https://prod.uidapi.com", + sdkVersion: "1.2.3" + ) + let urlRequest = client.urlRequest(request, baseURL: URL(string: "https://prod.uidapi.com")!) + + var expected = URLRequest(url: URL(string: "https://prod.uidapi.com/v2/token/refresh")!) + expected.httpMethod = "POST" + expected.httpBody = Data("im-a-refresh-token".utf8) + +#if os(tvOS) + expected.allHTTPHeaderFields = [ + "Content-Type": "application/x-www-form-urlencoded", + "X-UID2-Client-Version": "tvos-1.2.3" + ] +#else + expected.allHTTPHeaderFields = [ + "Content-Type": "application/x-www-form-urlencoded", + "X-UID2-Client-Version": "ios-1.2.3" + ] +#endif + XCTAssertEqual(urlRequest, expected) + + // The above equality test doesn't print useful information on failure, so + // it's useful to check properties below for diagnostics + XCTAssertEqual(urlRequest.url, expected.url) + XCTAssertEqual(urlRequest.httpMethod, expected.httpMethod) + XCTAssertEqual(urlRequest.httpBody, expected.httpBody) + XCTAssertEqual(urlRequest.allHTTPHeaderFields, expected.allHTTPHeaderFields) + } +} diff --git a/Tests/UID2Tests/RefreshTokenAPITests.swift b/Tests/UID2Tests/RefreshTokenAPITests.swift index 9afb3fa..5e7c70c 100644 --- a/Tests/UID2Tests/RefreshTokenAPITests.swift +++ b/Tests/UID2Tests/RefreshTokenAPITests.swift @@ -26,7 +26,11 @@ final class RefreshTokenAPITests: XCTestCase { } // Load UID2Client Mocked - let client = UID2Client(uid2APIURL: "", sdkVersion: "TEST", MockNetworkSession("refresh-token-200-success-encrypted", "txt")) + let client = UID2Client( + uid2APIURL: "https://prod.uidapi.com", + sdkVersion: "TEST", + MockNetworkSession("refresh-token-200-success-encrypted", "txt") + ) // Call RefreshToken using refreshToken and refreshResponseKey from Step 1 to decrypt let refreshToken = try await client.refreshIdentity(refreshToken: generateToken.refreshToken, @@ -65,7 +69,11 @@ final class RefreshTokenAPITests: XCTestCase { } // Load UID2Client Mocked - let client = UID2Client(uid2APIURL: "", sdkVersion: "TEST", MockNetworkSession("refresh-token-200-optout-encrypted", "txt")) + let client = UID2Client( + uid2APIURL: "https://prod.uidapi.com", + sdkVersion: "TEST", + MockNetworkSession("refresh-token-200-optout-encrypted", "txt") + ) // Call RefreshToken using refreshToken and refreshResponseKey from Step 1 to decrypt let refreshToken = try await client.refreshIdentity(refreshToken: generateToken.refreshToken, @@ -85,8 +93,12 @@ final class RefreshTokenAPITests: XCTestCase { do { // Load UID2Client Mocked - let client = UID2Client(uid2APIURL: "", sdkVersion: "TEST", MockNetworkSession("refresh-token-400-client-error", "json", 400)) - + let client = UID2Client( + uid2APIURL: "https://prod.uidapi.com", + sdkVersion: "TEST", + MockNetworkSession("refresh-token-400-client-error", "json", 400) + ) + // Call RefreshToken using refreshToken and refreshResponseKey from Step 1 to decrypt let _ = try await client.refreshIdentity(refreshToken: "token", refreshResponseKey: "key") XCTFail("refreshUID2Token() did not throw an error.") @@ -111,7 +123,11 @@ final class RefreshTokenAPITests: XCTestCase { do { // Load UID2Client Mocked - let client = UID2Client(uid2APIURL: "", sdkVersion: "TEST", MockNetworkSession("refresh-token-400-invalid-token", "json", 400)) + let client = UID2Client( + uid2APIURL: "https://prod.uidapi.com", + sdkVersion: "TEST", + MockNetworkSession("refresh-token-400-invalid-token", "json", 400) + ) // Call RefreshToken using refreshToken and refreshResponseKey from Step 1 to decrypt let _ = try await client.refreshIdentity(refreshToken: "token", refreshResponseKey: "key") @@ -137,7 +153,11 @@ final class RefreshTokenAPITests: XCTestCase { do { // Load UID2Client Mocked - let client = UID2Client(uid2APIURL: "", sdkVersion: "TEST", MockNetworkSession("refresh-token-401-unauthorized", "json", 401)) + let client = UID2Client( + uid2APIURL: "https://prod.uidapi.com", + sdkVersion: "TEST", + MockNetworkSession("refresh-token-401-unauthorized", "json", 401) + ) // Call RefreshToken using refreshToken and refreshResponseKey from Step 1 to decrypt let _ = try await client.refreshIdentity(refreshToken: "token", refreshResponseKey: "key")