diff --git a/CHANGELOG.md b/CHANGELOG.md index b1ad995..ed21872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ ## master +### Added +- Replaces custom solution for getting auth credentials with [google auth library](https://github.com/googleapis/google-auth-library-swift) ([#36](https://github.com/AckeeCZ/ACKLocalization/pull/36), kudos to @babacros) + ## 1.5.0 ## Added diff --git a/Package.resolved b/Package.resolved index fb5b734..d66f81f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -2,21 +2,57 @@ "object": { "pins": [ { - "package": "jwt-kit", - "repositoryURL": "https://github.com/vapor/jwt-kit", + "package": "BigInt", + "repositoryURL": "https://github.com/attaswift/BigInt", "state": { "branch": null, - "revision": "1822bb0abf0a31a4b5078ec19061c548835253b5", - "version": "4.3.0" + "revision": "0ed110f7555c34ff468e72e1686e59721f2b0da6", + "version": "5.3.0" } }, { - "package": "swift-crypto", - "repositoryURL": "https://github.com/apple/swift-crypto.git", + "package": "CryptoSwift", + "repositoryURL": "https://github.com/krzyzanowskim/CryptoSwift.git", "state": { "branch": null, - "revision": "a8911e0fadc25aef1071d582355bd1037a176060", - "version": "2.0.4" + "revision": "af1b58fc569bfde777462349b9f7314b61762be0", + "version": "1.3.2" + } + }, + { + "package": "Auth", + "repositoryURL": "https://github.com/googleapis/google-auth-library-swift.git", + "state": { + "branch": null, + "revision": "4b510d91fc74f1415eae6dabc9836b8c3e1f44f6", + "version": "0.5.3" + } + }, + { + "package": "swift-atomics", + "repositoryURL": "https://github.com/apple/swift-atomics.git", + "state": { + "branch": null, + "revision": "919eb1d83e02121cdb434c7bfc1f0c66ef17febe", + "version": "1.0.2" + } + }, + { + "package": "swift-collections", + "repositoryURL": "https://github.com/apple/swift-collections.git", + "state": { + "branch": null, + "revision": "f504716c27d2e5d4144fa4794b12129301d17729", + "version": "1.0.3" + } + }, + { + "package": "swift-nio", + "repositoryURL": "https://github.com/apple/swift-nio.git", + "state": { + "branch": null, + "revision": "edfceecba13d68c1c993382806e72f7e96feaa86", + "version": "2.44.0" } } ] diff --git a/Package.swift b/Package.swift index bd863b6..9ce97e4 100644 --- a/Package.swift +++ b/Package.swift @@ -18,14 +18,17 @@ let package = Package( targets: ["ACKLocalization"]), ], dependencies: [ - .package(url:"https://github.com/vapor/jwt-kit", .upToNextMajor(from: "4.3.0")), + .package( + url: "https://github.com/googleapis/google-auth-library-swift", + .upToNextMajor(from: "0.5.2") + ), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages which this package depends on. .target( name: "ACKLocalizationCore", - dependencies: ["JWTKit"]), + dependencies: ["OAuth2"]), .target( name: "ACKLocalization", dependencies: ["ACKLocalizationCore"]), diff --git a/Sources/ACKLocalizationCore/ACKLocalization.swift b/Sources/ACKLocalizationCore/ACKLocalization.swift index 0f6b391..95d3048 100644 --- a/Sources/ACKLocalizationCore/ACKLocalization.swift +++ b/Sources/ACKLocalizationCore/ACKLocalization.swift @@ -60,7 +60,7 @@ public final class ACKLocalization { /// Fetches content of given sheet from spreadsheet using given `serviceAccount` /// /// If not `spreadsheetTabName` is provided, the first in the spreadsheet is used - public func fetchSheetValues(_ spreadsheetTabName: String?, spreadsheetId: String, serviceAccount: ServiceAccount) -> AnyPublisher { + public func fetchSheetValues(_ spreadsheetTabName: String?, spreadsheetId: String, serviceAccount: Data) -> AnyPublisher { let sheetsAPI = self.sheetsAPI return authAPI.fetchAccessToken(serviceAccount: serviceAccount) @@ -326,9 +326,17 @@ public final class ACKLocalization { do { if let serviceAccountPath = config.serviceAccount { let serviceAccount = try loadServiceAccount(from: serviceAccountPath) - return fetchSheetValues(config.spreadsheetTabName, spreadsheetId: config.spreadsheetID, serviceAccount: serviceAccount) + return fetchSheetValues( + config.spreadsheetTabName, + spreadsheetId: config.spreadsheetID, + serviceAccount: serviceAccount + ) } else if let apiKey = config.apiKey { - return fetchSheetValues(config.spreadsheetTabName, spreadsheetId: config.spreadsheetID, apiKey: apiKey) + return fetchSheetValues( + config.spreadsheetTabName, + spreadsheetId: config.spreadsheetID, + apiKey: apiKey + ) } else if let serviceAccountPath = ProcessInfo.processInfo.environment[Constants.serviceAccountPath] { let serviceAccount = try loadServiceAccount(from: serviceAccountPath) return fetchSheetValues( @@ -338,16 +346,20 @@ public final class ACKLocalization { ) } else if let apiKey = ProcessInfo.processInfo.environment[Constants.apiKey] { let apiKey = APIKey(value: apiKey) - return fetchSheetValues(config.spreadsheetTabName, spreadsheetId: config.spreadsheetID, apiKey: apiKey) + return fetchSheetValues( + config.spreadsheetTabName, + spreadsheetId: config.spreadsheetID, + apiKey: apiKey + ) } else { let errorMessage = """ Unable to load API key or service account path. Please check if: - + - `apiKey` or `serviceAccount` attribute is provided in `localization.json` file or - `\(Constants.apiKey)` or `\(Constants.serviceAccountPath)` environment variable is set """ - + throw LocalizationError(message: errorMessage) } } catch { @@ -385,16 +397,16 @@ public final class ACKLocalization { } /// Loads service account from given `config` - private func loadServiceAccount(from path: String) throws -> ServiceAccount { + private func loadServiceAccount(from path: String) throws -> Data { guard let serviceAccountData = FileManager.default.contents(atPath: path) else { throw LocalizationError(message: "Unable to load service account at " + path) } - - do { - return try JSONDecoder().decode(ServiceAccount.self, from: serviceAccountData) - } catch { - throw LocalizationError(message: "Unable to read service account from `" + path + "` - " + error.localizedDescription) + + guard !serviceAccountData.isEmpty else { + throw LocalizationError(message: "Invalid service account data") } + + return serviceAccountData } /// Actually writes given `rows` to given `file` diff --git a/Sources/ACKLocalizationCore/Model/GoogleClaims.swift b/Sources/ACKLocalizationCore/Model/GoogleClaims.swift deleted file mode 100644 index cd05f5f..0000000 --- a/Sources/ACKLocalizationCore/Model/GoogleClaims.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// GoogleClaims.swift -// -// -// Created by Jakub Olejník on 11/12/2019. -// - -import Foundation -import JWTKit - -/// Struct that is used for generating second part of JWT token -struct GoogleClaims: JWTPayload { - enum Scope: String, Codable { - case readOnly = "https://www.googleapis.com/auth/spreadsheets.readonly" - } - - /// Service account email - let iss: String - - /// Required scope - let scope: Scope - - /// Desired auth endpoint - let aud: URL - - /// Date of expiration timestamp - let exp: Int - - /// Issued at date timestamp - let iat: Int -} - -extension GoogleClaims { - init(serviceAccount: ServiceAccount, scope: Scope, exp: Int, iat: Int) { - self.init(iss: serviceAccount.clientEmail, scope: scope, aud: serviceAccount.tokenURL, exp: exp, iat: iat) - } - - func verify(using signer: JWTSigner) throws { - try ExpirationClaim(value: Date(timeIntervalSince1970: TimeInterval(exp))).verifyNotExpired() - } -} diff --git a/Sources/ACKLocalizationCore/Services/AuthAPIService.swift b/Sources/ACKLocalizationCore/Services/AuthAPIService.swift index df38614..02b6b9f 100644 --- a/Sources/ACKLocalizationCore/Services/AuthAPIService.swift +++ b/Sources/ACKLocalizationCore/Services/AuthAPIService.swift @@ -7,12 +7,12 @@ import Combine import Foundation -import JWTKit +import OAuth2 /// Protocol wrapping a service that fetches an access token from further communication public protocol AuthAPIServicing { /// Fetch access token for given `serviceAccount` - func fetchAccessToken(serviceAccount: ServiceAccount) -> AnyPublisher + func fetchAccessToken(serviceAccount: Data) -> AnyPublisher } /// Service that fetches an access token from further communication @@ -28,41 +28,36 @@ public struct AuthAPIService: AuthAPIServicing { // MARK: - API calls /// Fetch access token for given `serviceAccount` - public func fetchAccessToken(serviceAccount: ServiceAccount) -> AnyPublisher { - let jwt = try? self.jwt( - for: serviceAccount, - claims: claims(serviceAccount: serviceAccount, validFor: 60) - ) - let requestData = AccessTokenRequest(assertion: jwt ?? "") - var request = URLRequest(url: serviceAccount.tokenURL) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try? JSONEncoder().encode(requestData) + public func fetchAccessToken(serviceAccount: Data) -> AnyPublisher { + Deferred { + Future { promise in + guard let tokenProvider = ServiceAccountTokenProvider( + credentialsData: serviceAccount, + scopes: ["https://www.googleapis.com/auth/spreadsheets.readonly"] + ) else { + promise(.failure(.init(message: "Creating provider failed"))) + return + } - return session.dataTaskPublisher(for: request) - .validate() - .decode(type: AccessToken.self, decoder: JSONDecoder()) - .mapError(RequestError.init) - .eraseToAnyPublisher() + do { + try tokenProvider.withToken { token, error in + if let token = token, let accessToken = token.AccessToken { + let token = AccessToken( + accessToken: accessToken, + expiration: TimeInterval(token.ExpiresIn ?? 0), + type: token.TokenType ?? "" + ) + promise(.success(token)) + } else if let error { + promise(.failure(.init(underlyingError: error, message: "Retrieving access token failed"))) + } else { + promise(.failure(.init(message: "Access token was not provided"))) + } + } + } catch { + promise(.failure(.init(underlyingError: error, message: "Retrieving access token failed"))) + } + } + }.eraseToAnyPublisher() } - - // MARK: - Private helpers - - /// Create JWT token that will be sent to retrieve access token - private func jwt(for serviceAccount: ServiceAccount, claims: GoogleClaims) throws -> String { - let signers = JWTSigners() - try signers.use(.rs256(key: .private(pem: serviceAccount.privateKey))) - return try signers.sign(claims) - } - - private func claims(serviceAccount sa: ServiceAccount, validFor interval: TimeInterval) -> GoogleClaims { - let now = Int(Date().timeIntervalSince1970) - - return .init( - serviceAccount: sa, - scope: .readOnly, - exp: now + Int(interval), - iat: now - ) - } } diff --git a/Tests/ACKLocalizationCoreTests/Mocks/AuthAPIServiceMock.swift b/Tests/ACKLocalizationCoreTests/Mocks/AuthAPIServiceMock.swift index dd9677b..6860b40 100644 --- a/Tests/ACKLocalizationCoreTests/Mocks/AuthAPIServiceMock.swift +++ b/Tests/ACKLocalizationCoreTests/Mocks/AuthAPIServiceMock.swift @@ -7,9 +7,10 @@ import ACKLocalizationCore import Combine +import Foundation final class AuthAPIServiceMock: AuthAPIServicing { - func fetchAccessToken(serviceAccount: ServiceAccount) -> AnyPublisher { + func fetchAccessToken(serviceAccount: Data) -> AnyPublisher { Empty().eraseToAnyPublisher() } }