Skip to content

Add tvOS support #23

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/test-pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
10 changes: 10 additions & 0 deletions .swiftpm/xcode/xcshareddata/xcschemes/UID2.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "UID2Tests"
BuildableName = "UID2Tests"
BlueprintName = "UID2Tests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import Foundation
import Security
import UID2

@available(iOS 13.0, *)
internal final class AppUID2Client {

enum RequestTypes: String, CaseIterable {
Expand Down
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ let package = Package(
name: "UID2",
defaultLocalization: "en",
platforms: [
.iOS(.v13)
.iOS(.v13),
.tvOS(.v13)
],
products: [
.library(
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ A framework for integrating [UID2](https://github.com/IABTechLab/uid2docs) into
| Platform | Minimum target | Swift Version |
| --- | --- | --- |
| iOS | 13.0+ | 5.0+ |
| tvOS | 13.0+ | 5.0+ |

## Development

Expand Down
1 change: 0 additions & 1 deletion Sources/UID2/KeychainManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import Foundation
import Security

/// Securely manages data in the Keychain
@available(iOS 13.0, *)
internal final class KeychainManager {

/// Singleton access point for KeychainManager
Expand Down
1 change: 0 additions & 1 deletion Sources/UID2/Networking/NetworkSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import Foundation

/// Common interface for networking and unit testing
@available(iOS 13.0, *)
protocol NetworkSession {

func loadData(for request: URLRequest) async throws -> (Data, Int)
Expand Down
23 changes: 23 additions & 0 deletions Sources/UID2/Networking/RefreshRequest.swift
Original file line number Diff line number Diff line change
@@ -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"
]
)
}
}
35 changes: 35 additions & 0 deletions Sources/UID2/Networking/Request.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
106 changes: 62 additions & 44 deletions Sources/UID2/UID2Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

import Foundation

@available(iOS 13.0, *)
internal final class UID2Client {

private let uid2APIURL: String
Expand All @@ -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)
}
}
1 change: 0 additions & 1 deletion Sources/UID2/UID2Error.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import Foundation

/// UID2 Specifc Errors

@available(iOS 13.0, *)
enum UID2Error: Error {

/// Unable to decrypt Payload Data
Expand Down
1 change: 0 additions & 1 deletion Sources/UID2/UID2Manager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import Combine
import Foundation

@available(iOS 13.0, *)
public final actor UID2Manager {

/// Singleton access point for UID2Manager
Expand Down
45 changes: 45 additions & 0 deletions Tests/UID2Tests/RefreshRequestTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
32 changes: 26 additions & 6 deletions Tests/UID2Tests/RefreshTokenAPITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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.")
Expand All @@ -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")
Expand All @@ -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")
Expand Down