diff --git a/CHANGELOG.md b/CHANGELOG.md index abdef2a..dd53294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # TransloaditKit Changelog -## 3.4 +## 3.5.0 + +* Allow clients to inject only an api key and provide a signature generator closure to calculate signatures for signing requests instead of injecting a key and secret. ([#42](https://github.com/transloadit/TransloaditKit/issues/42)) + +## 3.4.0 * Updated Package to depend on exact TUSKit version and removed call to removed method in TUSKit ([#41](https://github.com/transloadit/TransloaditKit/issues/41)) diff --git a/README.md b/README.md index 76771d0..fe905af 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,53 @@ dependencies: [ Start by initializing `Transloadit`. +### Simple initialization; pass key and secret to the SDK + ```swift let credentials = Transloadit.Credentials(key: "SomeKey", secret: "SomeSecret") let transloadit = Transloadit(credentials: credentials, session: URLSession.shared) ``` +Certain transloadit endpoints (can) require signatures to be included in their requests. The SDK can automatically generate signatures on your behalf but this requires you to pass both your Transloadit key _and_ secret to the SDK. + +The SDK does not persist your secret locally beyond the SDK's lifetime. + +This means that you're free to obtain your SDK secret in a secure manner from an external host or that you can include it in your app binary. It's up to you. + +It's also possible to initialize the SDK with a `nil` secret and manage signing yourself. + +### Advanced initialization; omit secret for manual request signing + +If, for security reasons, you choose to not expose your API secret to the app in any way, shape, or form, you can manage signature generation yourself. This allows you to generate signatures on your server and provide them to Transloadit as needed. + +To do this, use the `Transloadit` initializer that takes an api key and a `signatureGenerator` + +```swift +let transloadit = Transloadit( + apiKey: "YOUR-API-KEY", + sessionConfiguration: .default, + signatureGenerator: { stringToSign, onSignatureGenerated in + mySigningService.sign(stringToSign) { result in + onSignatureGenerated(result) + } + }) +``` + +The signature generator is defined as follows: + +```swift +public typealias SignatureCompletion = (Result) -> Void +public typealias SignatureGenerator = (String, SignatureCompletion) -> Void +``` + +The generator itself is passed a string that needs to be signed (a JSON representation of the request parameters that you're generating a signature for) and a closure that you _must_ call to inform the SDK when you're done generating the signature (whether it's successful or failed). + +**Important** if you don't call the completion handler, your requests will never be sent. The SDK does not implement a fallback or timeout. + +The SDK will invoke the signature generator for every request that requires a signature. It will pass a parameter string for each request to your closure which you can then send to your service (local or external) for signature generation. + +To learn more about signature generation see this page: https://transloadit.com/docs/api/authentication/ + ### Create an Assembly To create an `Assembly` you invoke `createAssembly(steps:andUpload:completion)` on `Transloadit`. diff --git a/Sources/TransloaditKit/Transloadit.swift b/Sources/TransloaditKit/Transloadit.swift index 6565d84..444cf70 100644 --- a/Sources/TransloaditKit/Transloadit.swift +++ b/Sources/TransloaditKit/Transloadit.swift @@ -3,13 +3,16 @@ import Foundation /// The errors that `Transloadit` can return public enum TransloaditError: Error { - case couldNotFetchStatus(underlyingError: Error) case couldNotCreateAssembly(underlyingError: Error) case couldNotUploadFile(underlyingError: Error) case couldNotClearCache(underlyingError: Error) } +public enum SDKConfigurationError: Error { + case missingClientSecret +} + public protocol TransloaditFileDelegate: AnyObject { func didStartUpload(assembly: Assembly, client: Transloadit) @@ -31,6 +34,9 @@ public protocol TransloaditFileDelegate: AnyObject { func didError(error: Error, client: Transloadit) } +public typealias SignatureCompletion = (Result) -> Void +public typealias SignatureGenerator = (String, SignatureCompletion) -> Void + /// Use the `Transloadit` class to upload files using the underlying TUS protocol. /// You can either create an Assembly by itself, or create an Assembly and upload files to it right away. /// @@ -42,9 +48,9 @@ public final class Transloadit { public struct Credentials { let key: String - let secret: String + let secret: String? - public init(key: String, secret: String) { + public init(key: String, secret: String?) { self.key = key self.secret = secret } @@ -84,7 +90,18 @@ public final class Transloadit { /// then TUS will make a directory, whether one you specify or a default one in the documents directory. @available(*, deprecated, message: "Use the new init(credentials:sessionConfig:storageDir:) instead.") public init(credentials: Transloadit.Credentials, session: URLSession, storageDir: URL? = nil) { - self.api = TransloaditAPI(credentials: credentials, session: session) + self.api = TransloaditAPI( + credentials: credentials, + session: session, + signatureGenerator: { parameterString, generationComplete in + guard let secret = credentials.secret, !secret.isEmpty else { + generationComplete(.failure(SDKConfigurationError.missingClientSecret)) + return + } + + generationComplete(.success("sha384:" + parameterString.hmac(key: secret))) + } + ) self.storageDir = storageDir self.tusSessionConfig = session.configuration.copy(withIdentifier: "com.transloadit.tus.bg") } @@ -97,7 +114,47 @@ public final class Transloadit { /// If left empty, no directory will be made when performing non-file related tasks, such as creating assemblies. However, if you start uploading files, /// then TUS will make a directory, whether one you specify or a default one in the documents directory. public init(credentials: Transloadit.Credentials, sessionConfiguration: URLSessionConfiguration, storageDir: URL? = nil) { - self.api = TransloaditAPI(credentials: credentials, sessionConfiguration: sessionConfiguration) + self.api = TransloaditAPI( + credentials: credentials, + sessionConfiguration: sessionConfiguration, + signatureGenerator: { parameterString, generationComplete in + guard let secret = credentials.secret, !secret.isEmpty else { + generationComplete(.failure(SDKConfigurationError.missingClientSecret)) + return + } + + generationComplete(.success("sha384:" + parameterString.hmac(key: secret))) + } + ) + self.storageDir = storageDir + self.tusSessionConfig = sessionConfiguration.copy(withIdentifier: "com.transloadit.tus.bg") + } + + /// Initialize Transloadit without a secret, providing a signature generator. + /// - Parameters: + /// - apiKey: Transloadit API key. + /// - sessionConfiguration: A URLSessionConfiguration to use. + /// - storageDir: A storagedirectory to use. Used by underlying TUSKit mechanism to store files. + /// If left empty, no directory will be made when performing non-file related tasks, such as creating assemblies. However, if you start uploading files, + /// then TUS will make a directory, whether one you specify or a default one in the documents directory. + /// - signatureGenerator: A closure that's invoked to generate the signature for the API request. Implement your own logic to generate a valid + /// signature. Call the provided completion handler with your signed string or an error as needed. + /// + /// For example, you can make a request to your backend to generate the signature for you. The closure is passed a string that holds all request params + /// that need to be signed. See https://transloadit.com/docs/api/authentication/ for more information on signature authentication. + /// The closure is invoked by the TransloaditAPI when needed. + /// + /// ** Important:** It's up to the caller to ensure that all codepaths (eventually) call the completion handler. The SDK does not implement any timeouts or fallbacks. + public init( + apiKey: String, sessionConfiguration: URLSessionConfiguration, + storageDir: URL? = nil, signatureGenerator: @escaping SignatureGenerator + ) { + let credentials = Transloadit.Credentials(key: apiKey, secret: nil) + self.api = TransloaditAPI( + credentials: credentials, + sessionConfiguration: sessionConfiguration, + signatureGenerator: signatureGenerator + ) self.storageDir = storageDir self.tusSessionConfig = sessionConfiguration.copy(withIdentifier: "com.transloadit.tus.bg") } diff --git a/Sources/TransloaditKit/TransloaditAPI.swift b/Sources/TransloaditKit/TransloaditAPI.swift index e0cb42e..eb3de91 100644 --- a/Sources/TransloaditKit/TransloaditAPI.swift +++ b/Sources/TransloaditKit/TransloaditAPI.swift @@ -15,6 +15,7 @@ enum TransloaditAPIError: Error { case couldNotCreateAssembly(Error) case assemblyError(String) case incompleteServerResponse + case apiIsNil } /// The `TransloaditAPI` class makes API calls, such as creating assemblies or checking an assembly's status. @@ -41,19 +42,23 @@ final class TransloaditAPI: NSObject { }() private let credentials: Transloadit.Credentials + private let signatureGenerator: SignatureGenerator + let callbacks = TransloaditCallbacks() - init(credentials: Transloadit.Credentials, session: URLSession) { + init(credentials: Transloadit.Credentials, session: URLSession, signatureGenerator: @escaping SignatureGenerator) { self.credentials = credentials self.configuration = session.configuration.copy(withIdentifier: "com.transloadit.bg") self.delegateQueue = session.delegateQueue + self.signatureGenerator = signatureGenerator super.init() } - init(credentials: Transloadit.Credentials, sessionConfiguration: URLSessionConfiguration) { + init(credentials: Transloadit.Credentials, sessionConfiguration: URLSessionConfiguration, signatureGenerator: @escaping SignatureGenerator) { self.credentials = credentials self.configuration = sessionConfiguration self.delegateQueue = nil + self.signatureGenerator = signatureGenerator super.init() } @@ -63,40 +68,17 @@ final class TransloaditAPI: NSObject { customFields: [String: String], completion: @escaping (Result) -> Void ) { - guard let request = try? makeAssemblyRequest( - templateId: templateId, - expectedNumberOfFiles: expectedNumberOfFiles, - customFields: customFields - ) else { - // Next runloop to make the API consistent with the network runloop. Otherwise it would return instantly, can give weird effects - DispatchQueue.main.async { - completion(.failure(TransloaditAPIError.cantSerialize)) - } - return - } + let params: [String: Any] = [ + "template_id": templateId, + "fields": customFields + ] - let task = session.uploadTask(with: request.request, fromFile: request.httpBody) - callbacks.register(URLSessionCompletionHandler(callback: { result in - switch result { - case .failure(let error): - completion(.failure(.couldNotCreateAssembly(error))) - case .success((let data, _)): - do { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - let assembly = try decoder.decode(Assembly.self, from: data) - - if let error = assembly.error { - completion(.failure(.assemblyError(error))) - } else { - completion(.success(assembly)) - } - } catch { - completion(.failure(TransloaditAPIError.couldNotCreateAssembly(error))) - } - } - }), for: task) - task.resume() + createAssembly( + params: params, + expectedNumberOfFiles: expectedNumberOfFiles, + customFields: customFields, + completion: completion + ) } func createAssembly( @@ -105,128 +87,152 @@ final class TransloaditAPI: NSObject { customFields: [String: String], completion: @escaping (Result) -> Void ) { - guard let request = try? makeAssemblyRequest( - steps: steps, - expectedNumberOfFiles: expectedNumberOfFiles, - customFields: customFields - ) else { - // Next runloop to make the API consistent with the network runloop. Otherwise it would return instantly, can give weird effects - DispatchQueue.main.async { - completion(.failure(TransloaditAPIError.cantSerialize)) - } - return - } + let params = [ + "steps": steps.toDictionary + ] - let task = session.uploadTask(with: request.request, fromFile: request.httpBody) - callbacks.register(URLSessionCompletionHandler(callback: { result in + createAssembly( + params: params, + expectedNumberOfFiles: expectedNumberOfFiles, + customFields: customFields, + completion: completion + ) + } + + private func createAssembly( + params: [String: Any], + expectedNumberOfFiles: Int, + customFields: [String: String], + completion: @escaping (Result) -> Void + ) { + makeAssemblyRequest( + params: params, + expectedNumberOfFiles: expectedNumberOfFiles, + customFields: customFields + ) { result in switch result { - case .failure(let error): - completion(.failure(.couldNotCreateAssembly(error))) - case .success((let data, _)): - do { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - let assembly = try decoder.decode(Assembly.self, from: data) - - if let error = assembly.error { - completion(.failure(.assemblyError(error))) - } else { - completion(.success(assembly)) - } - } catch { - completion(.failure(TransloaditAPIError.couldNotCreateAssembly(error))) + case .failure: + DispatchQueue.main.async { + completion(.failure(TransloaditAPIError.cantSerialize)) } + case .success((let request, let httpBody)): + let task = self.session.uploadTask(with: request, fromFile: httpBody) + self.callbacks.register(URLSessionCompletionHandler(callback: { result in + switch result { + case .failure(let error): + completion(.failure(.couldNotCreateAssembly(error))) + case .success((let data, _)): + do { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let assembly = try decoder.decode(Assembly.self, from: data) + + if let error = assembly.error { + completion(.failure(.assemblyError(error))) + } else { + completion(.success(assembly)) + } + } catch { + completion(.failure(.couldNotCreateAssembly(error))) + } + } + }), for: task) + task.resume() } - }), for: task) - task.resume() + } + } private func makeAssemblyRequest( - templateId: String, - expectedNumberOfFiles: Int, - customFields: [String: String] - ) throws -> (request: URLRequest, httpBody: URL) { + params: [String: Any], + expectedNumberOfFiles: Int, + customFields: [String: String], + assemblyRequestCreated: @escaping (Result<(request: URLRequest, httpBody: URL), Error>) -> Void + ) { + let boundary = UUID.init().uuidString - func makeBody(includeSecret: Bool) throws -> [String: String] { - // Time to allow uploads after signing. - let secondsInDay: Double = 86400 - let dateTime: String = type(of: self).formatter.string(from: Date().addingTimeInterval(secondsInDay)) - - let authObject = ["key": credentials.key, "expires": dateTime] - - var params: [String: Any] = ["auth": authObject, "template_id": templateId] - params["fields"] = customFields - - let paramsData: Data - if #available(macOS 10.15, iOS 13.0, *) { - paramsData = try JSONSerialization.data(withJSONObject: params, options: .withoutEscapingSlashes) - } else { - paramsData = try! JSONSerialization.data(withJSONObject: params, options: []) - } + do { + let request = try assemblyURLRequest(boundary: boundary) - guard let paramsJSONString = String(data: paramsData, encoding: .utf8) else { - throw TransloaditAPIError.cantSerialize - } - - var body: [String: String] = ["params": paramsJSONString, "tus_num_expected_upload_files": String(expectedNumberOfFiles)] - if !credentials.secret.isEmpty { - body["signature"] = "sha384:" + paramsJSONString.hmac(key: credentials.secret) + makeBodyDataForAssemblyRequest( + using: params, + expectedNumberOfFiles: expectedNumberOfFiles, + boundary: boundary + ) { [weak self] result in + guard let self else { + assemblyRequestCreated(.failure(TransloaditError.couldNotCreateAssembly(underlyingError: TransloaditAPIError.apiIsNil))) + return + } + do { + let bodyData = try result.get() + let bodyURL = try self.writeBodyData(bodyData) + assemblyRequestCreated(.success((request, bodyURL))) + } catch { + assemblyRequestCreated(.failure(TransloaditError.couldNotCreateAssembly(underlyingError: error))) + } } - - return body + } catch { + assemblyRequestCreated(.failure(TransloaditError.couldNotCreateAssembly(underlyingError: error))) } + } + + private func assemblyURLRequest(boundary: String) throws -> URLRequest { + let path = basePath.appendingPathComponent(Endpoint.assemblies.rawValue) + var request: URLRequest = URLRequest(url: path, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 30) - let boundary = UUID.init().uuidString + let headers = ["Content-Type": String(format: "multipart/form-data; boundary=%@", boundary)] - func makeBodyData() throws -> Data { - let formFields = try makeBody(includeSecret: true) - var body: Data = Data() - for field in formFields { - [String(format: "--%@\r\n", boundary), - String(format: "Content-Disposition: form-data; name=\"%@\"\r\n\r\n", field.key), - String(format: "%@\r\n", field.value)] - .forEach { string in - body.append(Data(string.utf8)) - } + request.httpMethod = "POST" + request.allHTTPHeaderFields = headers + return request + } + + private func makeBodyDataForAssemblyRequest( + using params: [String: Any], + expectedNumberOfFiles: Int, + boundary: String, + bodyDataCreated: @escaping (Result) -> Void + ) { + makeBodyForAssemblyRequest( + using: params, + expectedNumberOfFiles: expectedNumberOfFiles + ) { result in + do { + let formFields = try result.get() + var body: Data = Data() + for field in formFields { + [String(format: "--%@\r\n", boundary), + String(format: "Content-Disposition: form-data; name=\"%@\"\r\n\r\n", field.key), + String(format: "%@\r\n", field.value)] + .forEach { string in + body.append(Data(string.utf8)) + } + } + let string = String(format: "--%@--\r\n", boundary) + body.append(Data(string.utf8)) + + bodyDataCreated(.success(body)) + } catch { + bodyDataCreated(.failure(TransloaditError.couldNotCreateAssembly(underlyingError: error))) } - let string = String(format: "--%@--\r\n", boundary) - body.append(Data(string.utf8)) - return body - } - - func makeRequest() throws -> URLRequest { - let path = basePath.appendingPathComponent(Endpoint.assemblies.rawValue) - var request: URLRequest = URLRequest(url: path, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 30) - - let headers = ["Content-Type": String(format: "multipart/form-data; boundary=%@", boundary)] - - request.httpMethod = "POST" - request.allHTTPHeaderFields = headers - return request } - - let request = try makeRequest() - let bodyData = try makeBodyData() - - return (request, try writeBodyData(bodyData)) } - private func makeAssemblyRequest( - steps: [Step], - expectedNumberOfFiles: Int, - customFields: [String: String] - ) throws -> (request: URLRequest, httpBody: URL) { + private func makeBodyForAssemblyRequest( + using params: [String: Any], + expectedNumberOfFiles: Int, + bodyCreated: @escaping (Result<[String: String], Error>) -> Void + ) { + var params = params - func makeBody(includeSecret: Bool) throws -> [String: String] { - // Time to allow uploads after signing. - let secondsInDay: Double = 86400 - let dateTime: String = type(of: self).formatter.string(from: Date().addingTimeInterval(secondsInDay)) - - let authObject = ["key": credentials.key, "expires": dateTime] - - var params: [String: Any] = ["auth": authObject, "steps": steps.toDictionary] - params["fields"] = customFields - + // Time to allow uploads after signing. + let secondsInDay: Double = 86400 + let dateTime: String = type(of: self).formatter.string(from: Date().addingTimeInterval(secondsInDay)) + + let authObject = ["key": credentials.key, "expires": dateTime] + params["auth"] = authObject + + do { let paramsData: Data if #available(macOS 10.15, iOS 13.0, *) { paramsData = try JSONSerialization.data(withJSONObject: params, options: .withoutEscapingSlashes) @@ -234,52 +240,27 @@ final class TransloaditAPI: NSObject { paramsData = try JSONSerialization.data(withJSONObject: params, options: []) } + guard let paramsJSONString = String(data: paramsData, encoding: .utf8) else { throw TransloaditAPIError.cantSerialize } var body: [String: String] = ["params": paramsJSONString, "tus_num_expected_upload_files": String(expectedNumberOfFiles)] - if !credentials.secret.isEmpty { - body["signature"] = "sha384:" + paramsJSONString.hmac(key: credentials.secret) - } - - return body - } - - let boundary = UUID.init().uuidString - - func makeBodyData() throws -> Data { - let formFields = try makeBody(includeSecret: true) - var body: Data = Data() - for field in formFields { - [String(format: "--%@\r\n", boundary), - String(format: "Content-Disposition: form-data; name=\"%@\"\r\n\r\n", field.key), - String(format: "%@\r\n", field.value)] - .forEach { string in - body.append(Data(string.utf8)) - } + signatureGenerator(paramsJSONString) { signatureResult in + do { + let signature = try signatureResult.get() + body["signature"] = signature + bodyCreated(.success(body)) + } catch { + bodyCreated(.failure(TransloaditError.couldNotCreateAssembly(underlyingError: error))) + } } - let string = String(format: "--%@--\r\n", boundary) - body.append(Data(string.utf8)) - return body - } - - func makeRequest() throws -> URLRequest { - let path = basePath.appendingPathComponent(Endpoint.assemblies.rawValue) - var request: URLRequest = URLRequest(url: path, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 30) + } catch { + bodyCreated(.failure(TransloaditError.couldNotCreateAssembly(underlyingError: error))) - let headers = ["Content-Type": String(format: "multipart/form-data; boundary=%@", boundary)] - - request.httpMethod = "POST" - request.allHTTPHeaderFields = headers - return request } - - let request = try makeRequest() - let bodyData = try makeBodyData() - - return (request, try writeBodyData(bodyData)) } + private func writeBodyData(_ data: Data) throws -> URL { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! diff --git a/Tests/TransloaditKitTests/TransloaditKitTests.swift b/Tests/TransloaditKitTests/TransloaditKitTests.swift index a803fd7..a6bf927 100644 --- a/Tests/TransloaditKitTests/TransloaditKitTests.swift +++ b/Tests/TransloaditKitTests/TransloaditKitTests.swift @@ -37,13 +37,36 @@ class TransloaditKitTests: XCTestCase { let configuration = URLSessionConfiguration.default configuration.protocolClasses = [MockURLProtocol.self] - let session = URLSession.init(configuration: configuration) - return Transloadit(credentials: credentials, session: session) + return Transloadit(credentials: credentials, sessionConfiguration: configuration) + } + + func testCreateAssembly_Calls_Injected_Signature_Generator() throws { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + + let signatureExpectation = expectation(description: "Waiting for signature to be requested") + let client = Transloadit(apiKey: "I am a key", sessionConfiguration: configuration, signatureGenerator: { params, completion in + signatureExpectation.fulfill() + completion(.success("signed:" + params)) + }) + + let serverAssembly = Fixtures.makeAssembly() + Network.prepareAssemblyResponse(assembly: serverAssembly) + let serverFinishedExpectation = expectation(description: "Waiting for createAssembly to be called") + client.createAssembly(steps: [resizeStep]) { result in + switch result { + case .success: + serverFinishedExpectation.fulfill() + case .failure: + XCTFail("Creating an assembly should have succeeded") + } + } + + waitForExpectations(timeout: 3.0, handler: nil) } // MARK: - File uploading - func testCreateAssembly_Without_Uploading() throws { let serverAssembly = Fixtures.makeAssembly() Network.prepareAssemblyResponse(assembly: serverAssembly) diff --git a/Transloadit.podspec b/Transloadit.podspec index 3c8502d..651c40c 100644 --- a/Transloadit.podspec +++ b/Transloadit.podspec @@ -8,7 +8,7 @@ Pod::Spec.new do |s| s.name = 'Transloadit' - s.version = '3.4.0' + s.version = '3.5.0' s.summary = 'Transloadit client in Swift' s.swift_version = '5.0'