Skip to content
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

Add support for IAMServiceAccountCredentials API #54

Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import AsyncHTTPClient
import NIO
import Foundation
import JWTKit

public protocol IAMServiceAccountCredentialsAPI {
func signJWT(_ jwt: JWTPayload, delegates: [String], serviceAccount: String) throws -> EventLoopFuture<SignJWTResponse>
}

public extension IAMServiceAccountCredentialsAPI {
func signJWT(_ jwt: JWTPayload, delegates: [String] = [], serviceAccount: String) throws -> EventLoopFuture<SignJWTResponse> {
try signJWT(jwt, delegates: delegates, serviceAccount: serviceAccount)
}
}

public final class GoogleCloudServiceAccountCredentialsAPI: IAMServiceAccountCredentialsAPI {

let endpoint: String
let request: IAMServiceAccountCredentialsRequest
private let encoder = JSONEncoder()

init(request: IAMServiceAccountCredentialsRequest,
endpoint: String) {
self.request = request
self.endpoint = endpoint
}

public func signJWT(_ jwt: JWTPayload, delegates: [String] = [], serviceAccount: String) throws -> EventLoopFuture<SignJWTResponse> {

do {
let signJWTRequest = try SignJWTRequest(jwt: jwt, delegates: delegates)
let body = try HTTPClient.Body.data(encoder.encode(signJWTRequest))

return request.send(method: .POST, path: "\(endpoint)/v1/projects/-/serviceAccounts/\(serviceAccount):signJwt", body: body)
} catch {
return request.eventLoop.makeFailedFuture(error)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Core
import Foundation
import AsyncHTTPClient
import NIO

public final class IAMServiceAccountCredentialsClient {

public var api: IAMServiceAccountCredentialsAPI
var request: IAMServiceAccountCredentialsRequest

/// Initialize a client for interacting with the Google Cloud IAM Service Account Credentials API
/// - Parameter credentials: The Credentials to use when authenticating with the APIs
/// - Parameter config: The configuration for the IAM Service Account Credentials API
/// - Parameter httpClient: An `HTTPClient` used for making API requests.
/// - Parameter eventLoop: The EventLoop used to perform the work on.
/// - Parameter base: The base URL to use for the IAM Service Account Credentials API
public init(
credentials: GoogleCloudCredentialsConfiguration,
config: IAMServiceAccountCredentialsConfiguration,
httpClient: HTTPClient,
eventLoop: EventLoop,
base: String = "https://iamcredentials.googleapis.com"
) throws {

/// A token implementing `OAuthRefreshable`. Loaded from credentials specified by `GoogleCloudCredentialsConfiguration`.
let refreshableToken = OAuthCredentialLoader.getRefreshableToken(
credentials: credentials,
withConfig: config,
andClient: httpClient,
eventLoop: eventLoop
)

/// Set the projectId to use for this client. In order of priority:
/// - Environment Variable (GOOGLE_PROJECT_ID)
/// - Environment Variable (PROJECT_ID)
/// - Service Account's projectID
/// - `IAMServiceAccountCredentialsConfiguration` `project` property (optionally configured).
/// - `GoogleCloudCredentialsConfiguration's` `project` property (optionally configured).

guard let projectId = ProcessInfo.processInfo.environment["GOOGLE_PROJECT_ID"] ??
ProcessInfo.processInfo.environment["PROJECT_ID"] ??
(refreshableToken as? OAuthServiceAccount)?.credentials.projectId ??
config.project ?? credentials.project
else {
throw IAMServiceAccountCredentialsError.projectIdMissing
}

request = IAMServiceAccountCredentialsRequest(
httpClient: httpClient,
eventLoop: eventLoop,
oauth: refreshableToken,
project: projectId
)

api = GoogleCloudServiceAccountCredentialsAPI(
request: request,
endpoint: base
)
}

/// Hop to a new eventloop to execute requests on.
/// - Parameter eventLoop: The eventloop to execute requests on.
public func hopped(to eventLoop: EventLoop) -> IAMServiceAccountCredentialsClient {
request.eventLoop = eventLoop
return self
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Core

public struct IAMServiceAccountCredentialsConfiguration: GoogleCloudAPIConfiguration {
public var scope: [GoogleCloudAPIScope]
public let serviceAccount: String
public let project: String?
public let subscription: String? = nil

public init(scope: [GoogleCloudIAMServiceAccountCredentialsScope], serviceAccount: String, project: String?) {
self.scope = scope
self.serviceAccount = serviceAccount
self.project = project
}

/// Create a new `IAMServiceAccountCredentialsConfiguration` with cloud platform scope and the default service account.
public static func `default`() -> IAMServiceAccountCredentialsConfiguration {
return IAMServiceAccountCredentialsConfiguration(scope: [.cloudPlatform],
serviceAccount: "default",
project: nil)
}
}

public enum GoogleCloudIAMServiceAccountCredentialsScope: GoogleCloudAPIScope {
/// View and manage your data across Google Cloud Platform services

case cloudPlatform
case iam

public var value: String {
return switch self {
case .cloudPlatform: "https://www.googleapis.com/auth/cloud-platform"
case .iam: "https://www.googleapis.com/auth/iam"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Core
import Foundation

public enum IAMServiceAccountCredentialsError: GoogleCloudError {
case projectIdMissing
case jwtEncodingFailed
case jwtConversionFailed
case unknownError(String)

var localizedDescription: String {
return switch self {
case .projectIdMissing:
"Missing project id for GoogleCloudIAMServiceAccountCredentials API. Did you forget to set your project id?"
case .unknownError(let reason):
"An unknown error occurred: \(reason)"
case .jwtEncodingFailed:
"Failed to encode JWT as JSON"
case .jwtConversionFailed:
"Failed to convert encoded JWT to String"
}
}
}

public struct IAMServiceAccountCredentialsAPIError: GoogleCloudError, GoogleCloudModel {
/// A container for the error information.
public var error: IAMServiceAccountCredentialsAPIErrorBody
}

public struct IAMServiceAccountCredentialsAPIErrorBody: Codable {
/// A container for the error details.
public var status: String
/// An HTTP status code value, without the textual description.
public var code: Int
/// Description of the error. Same as `errors.message`.
public var message: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import Core
import Foundation
import NIO
import NIOFoundationCompat
import NIOHTTP1
import AsyncHTTPClient

class IAMServiceAccountCredentialsRequest: GoogleCloudAPIRequest {

let refreshableToken: OAuthRefreshable
let project: String
let httpClient: HTTPClient
let responseDecoder: JSONDecoder = JSONDecoder()
var currentToken: OAuthAccessToken?
var tokenCreatedTime: Date?
var eventLoop: EventLoop

init(httpClient: HTTPClient, eventLoop: EventLoop, oauth: OAuthRefreshable, project: String) {
self.refreshableToken = oauth
self.httpClient = httpClient
self.project = project
self.eventLoop = eventLoop
let dateFormatter = DateFormatter()

dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
self.responseDecoder.dateDecodingStrategy = .formatted(dateFormatter)
}

public func send<GCM: GoogleCloudModel>(
method: HTTPMethod,
headers: HTTPHeaders = [:],
path: String,
query: String = "",
body: HTTPClient.Body = .data(Data())
) -> EventLoopFuture<GCM> {

return withToken { token in

return self._send(
method: method,
headers: headers,
path: path,
query: query,
body: body,
accessToken: token.accessToken
).flatMap { response in
do {
let model = try self.responseDecoder.decode(GCM.self, from: response)
return self.eventLoop.makeSucceededFuture(model)
} catch {
return self.eventLoop.makeFailedFuture(error)
}
}
}
}

private func _send(
method: HTTPMethod,
headers: HTTPHeaders,
path: String,
query: String,
body: HTTPClient.Body,
accessToken: String
) -> EventLoopFuture<Data> {

var _headers: HTTPHeaders = ["Authorization": "Bearer \(accessToken)",
"Content-Type": "application/json"]
headers.forEach { _headers.replaceOrAdd(name: $0.name, value: $0.value) }

do {
let request = try HTTPClient.Request(url: "\(path)?\(query)", method: method, headers: _headers, body: body)

return httpClient.execute(
request: request,
eventLoop: .delegate(on: self.eventLoop)
).flatMap { response in

guard var byteBuffer = response.body else {
fatalError("Response body from Google is missing! This should never happen.")
}
let responseData = byteBuffer.readData(length: byteBuffer.readableBytes)!

guard (200...299).contains(response.status.code) else {
let error: Error
if let jsonError = try? self.responseDecoder.decode(IAMServiceAccountCredentialsAPIError.self, from: responseData) {
error = jsonError
} else {
let body = response.body?.getString(at: response.body?.readerIndex ?? 0, length: response.body?.readableBytes ?? 0) ?? ""
error = IAMServiceAccountCredentialsAPIError(error: IAMServiceAccountCredentialsAPIErrorBody(status: "unknownError", code: Int(response.status.code), message: body))
}

return self.eventLoop.makeFailedFuture(error)
}
return self.eventLoop.makeSucceededFuture(responseData)
}
} catch {
return self.eventLoop.makeFailedFuture(error)
}
}
}
63 changes: 63 additions & 0 deletions IAMServiceAccountCredentials/Sources/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Google Cloud IAM Service Account Credentials

## Using the IAM Service Account Credentials API

### Setting up IAMServiceAccountCredentialsConfiguration

To make GoogleCloudKit as flexible as possible to work with different API's and projects,
you can configure each API with their own configuration if the default `GoogleCloudCredentialsConfiguration` doesn't satisfy your needs.

For example the `GoogleCloudCredentialsConfiguration` can be configured with a `ProjectID`, but you might
want to use this specific API with a different project than other APIs. Additionally every API has their own scope and you might want to configure.
To use the IAM Service Account Credentials API you can create a `GoogleCloudIAMServiceAccountCredentialsConfiguration` in one of 2 ways.

```swift
let credentialsConfiguration = GoogleCloudCredentialsConfiguration(project: "my-project-1",
credentialsFile: "/path/to/service-account.json")

let iamServiceAccountCredentialsConfiguration = IAMServiceAccountCredentialsConfiguration(scope: [.cloudPlatform],
serviceAccount: "default",
project: "my-project-2")
// OR
let iamServiceAccountCredentialsConfiguration = IAMServiceAccountCredentialsConfiguration.default()
// has full control access and uses default service account with no project specified.
```

### Now create an `IAMServiceAccountCredentialsClient` with the configuration and an `HTTPClient`
```swift
let client = HTTPClient(...)
let iamClient = try IAMServiceAccountCredentialsClient(credentials: credentialsConfiguration,
config: iamServiceAccountCredentialsConfiguration,
httpClient: client,
eventLoop: myEventLoop)

```
The order of priority for which configured projectID the IAMServiceAccountCredentialsClient will use is as follows:
1. `$GOOGLE_PROJECT_ID` environment variable.
1. `$PROJECT_ID` environment variable.
2. The Service Accounts projectID (Service account configured via the credentials path in the credentials configuration).
3. `IAMServiceAccountCredentialsConfiguration`'s `project` property.
4. `GoogleCloudCredentialsConfiguration`'s `project` property.

Initializing the client will throw an error if no projectID is set anywhere.

### Signing a JWT

```swift
func signJWT() {
let client = try IAMServiceAccountCredentialsClient(credentials: credentialsConfiguration,
config: IAMServiceAccountCredentialsConfiguration,
httpClient: client,
eventLoop: myEventLoop)

let payload: JWTPayload = MyPayload(name: "key", value: "value")

client.api.signJWT(payload, serviceAccount: "[email protected]").map { response in
print(response.signedJwt) // Prints JWT signed with the given service account's credentials
}
}
```
### What's implemented

#### IAM Service Account Credentials API
* [x] signJWT
27 changes: 27 additions & 0 deletions IAMServiceAccountCredentials/Sources/Request/SignJWTRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Core
import Foundation
import JWTKit

public struct SignJWTRequest: GoogleCloudModel {

public init(jwt: JWTPayload, delegates: [String] = []) throws {

let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .integerSecondsSince1970

guard let data = try? encoder.encode(jwt) else {
throw IAMServiceAccountCredentialsError.jwtEncodingFailed
}

guard let payload = String(data: data, encoding: .utf8) else {
throw IAMServiceAccountCredentialsError.jwtConversionFailed
}

self.payload = payload
self.delegates = delegates
}

public let payload: String
public let delegates: [String]
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Core

public struct SignJWTResponse: GoogleCloudModel {

public let keyId: String
public let signedJwt: String
}
Loading