Skip to content

Commit aba2423

Browse files
authored
Merge pull request #54 from owainhunt/iam-service-account-credentials
Add support for IAMServiceAccountCredentials API
2 parents c9f9752 + 2eb40d6 commit aba2423

10 files changed

+394
-1
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import AsyncHTTPClient
2+
import NIO
3+
import Foundation
4+
import JWTKit
5+
6+
public protocol IAMServiceAccountCredentialsAPI {
7+
func signJWT(_ jwt: JWTPayload, delegates: [String], serviceAccount: String) throws -> EventLoopFuture<SignJWTResponse>
8+
}
9+
10+
public extension IAMServiceAccountCredentialsAPI {
11+
func signJWT(_ jwt: JWTPayload, delegates: [String] = [], serviceAccount: String) throws -> EventLoopFuture<SignJWTResponse> {
12+
try signJWT(jwt, delegates: delegates, serviceAccount: serviceAccount)
13+
}
14+
}
15+
16+
public final class GoogleCloudServiceAccountCredentialsAPI: IAMServiceAccountCredentialsAPI {
17+
18+
let endpoint: String
19+
let request: IAMServiceAccountCredentialsRequest
20+
private let encoder = JSONEncoder()
21+
22+
init(request: IAMServiceAccountCredentialsRequest,
23+
endpoint: String) {
24+
self.request = request
25+
self.endpoint = endpoint
26+
}
27+
28+
public func signJWT(_ jwt: JWTPayload, delegates: [String] = [], serviceAccount: String) throws -> EventLoopFuture<SignJWTResponse> {
29+
30+
do {
31+
let signJWTRequest = try SignJWTRequest(jwt: jwt, delegates: delegates)
32+
let body = try HTTPClient.Body.data(encoder.encode(signJWTRequest))
33+
34+
return request.send(method: .POST, path: "\(endpoint)/v1/projects/-/serviceAccounts/\(serviceAccount):signJwt", body: body)
35+
} catch {
36+
return request.eventLoop.makeFailedFuture(error)
37+
}
38+
}
39+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import Core
2+
import Foundation
3+
import AsyncHTTPClient
4+
import NIO
5+
6+
public final class IAMServiceAccountCredentialsClient {
7+
8+
public var api: IAMServiceAccountCredentialsAPI
9+
var request: IAMServiceAccountCredentialsRequest
10+
11+
/// Initialize a client for interacting with the Google Cloud IAM Service Account Credentials API
12+
/// - Parameter credentials: The Credentials to use when authenticating with the APIs
13+
/// - Parameter config: The configuration for the IAM Service Account Credentials API
14+
/// - Parameter httpClient: An `HTTPClient` used for making API requests.
15+
/// - Parameter eventLoop: The EventLoop used to perform the work on.
16+
/// - Parameter base: The base URL to use for the IAM Service Account Credentials API
17+
public init(
18+
credentials: GoogleCloudCredentialsConfiguration,
19+
config: IAMServiceAccountCredentialsConfiguration,
20+
httpClient: HTTPClient,
21+
eventLoop: EventLoop,
22+
base: String = "https://iamcredentials.googleapis.com"
23+
) throws {
24+
25+
/// A token implementing `OAuthRefreshable`. Loaded from credentials specified by `GoogleCloudCredentialsConfiguration`.
26+
let refreshableToken = OAuthCredentialLoader.getRefreshableToken(
27+
credentials: credentials,
28+
withConfig: config,
29+
andClient: httpClient,
30+
eventLoop: eventLoop
31+
)
32+
33+
/// Set the projectId to use for this client. In order of priority:
34+
/// - Environment Variable (GOOGLE_PROJECT_ID)
35+
/// - Environment Variable (PROJECT_ID)
36+
/// - Service Account's projectID
37+
/// - `IAMServiceAccountCredentialsConfiguration` `project` property (optionally configured).
38+
/// - `GoogleCloudCredentialsConfiguration's` `project` property (optionally configured).
39+
40+
guard let projectId = ProcessInfo.processInfo.environment["GOOGLE_PROJECT_ID"] ??
41+
ProcessInfo.processInfo.environment["PROJECT_ID"] ??
42+
(refreshableToken as? OAuthServiceAccount)?.credentials.projectId ??
43+
config.project ?? credentials.project
44+
else {
45+
throw IAMServiceAccountCredentialsError.projectIdMissing
46+
}
47+
48+
request = IAMServiceAccountCredentialsRequest(
49+
httpClient: httpClient,
50+
eventLoop: eventLoop,
51+
oauth: refreshableToken,
52+
project: projectId
53+
)
54+
55+
api = GoogleCloudServiceAccountCredentialsAPI(
56+
request: request,
57+
endpoint: base
58+
)
59+
}
60+
61+
/// Hop to a new eventloop to execute requests on.
62+
/// - Parameter eventLoop: The eventloop to execute requests on.
63+
public func hopped(to eventLoop: EventLoop) -> IAMServiceAccountCredentialsClient {
64+
request.eventLoop = eventLoop
65+
return self
66+
}
67+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import Core
2+
3+
public struct IAMServiceAccountCredentialsConfiguration: GoogleCloudAPIConfiguration {
4+
public var scope: [GoogleCloudAPIScope]
5+
public let serviceAccount: String
6+
public let project: String?
7+
public let subscription: String? = nil
8+
9+
public init(scope: [GoogleCloudIAMServiceAccountCredentialsScope], serviceAccount: String, project: String?) {
10+
self.scope = scope
11+
self.serviceAccount = serviceAccount
12+
self.project = project
13+
}
14+
15+
/// Create a new `IAMServiceAccountCredentialsConfiguration` with cloud platform scope and the default service account.
16+
public static func `default`() -> IAMServiceAccountCredentialsConfiguration {
17+
return IAMServiceAccountCredentialsConfiguration(scope: [.cloudPlatform],
18+
serviceAccount: "default",
19+
project: nil)
20+
}
21+
}
22+
23+
public enum GoogleCloudIAMServiceAccountCredentialsScope: GoogleCloudAPIScope {
24+
/// View and manage your data across Google Cloud Platform services
25+
26+
case cloudPlatform
27+
case iam
28+
29+
public var value: String {
30+
return switch self {
31+
case .cloudPlatform: "https://www.googleapis.com/auth/cloud-platform"
32+
case .iam: "https://www.googleapis.com/auth/iam"
33+
}
34+
}
35+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import Core
2+
import Foundation
3+
4+
public enum IAMServiceAccountCredentialsError: GoogleCloudError {
5+
case projectIdMissing
6+
case jwtEncodingFailed
7+
case jwtConversionFailed
8+
case unknownError(String)
9+
10+
var localizedDescription: String {
11+
return switch self {
12+
case .projectIdMissing:
13+
"Missing project id for GoogleCloudIAMServiceAccountCredentials API. Did you forget to set your project id?"
14+
case .unknownError(let reason):
15+
"An unknown error occurred: \(reason)"
16+
case .jwtEncodingFailed:
17+
"Failed to encode JWT as JSON"
18+
case .jwtConversionFailed:
19+
"Failed to convert encoded JWT to String"
20+
}
21+
}
22+
}
23+
24+
public struct IAMServiceAccountCredentialsAPIError: GoogleCloudError, GoogleCloudModel {
25+
/// A container for the error information.
26+
public var error: IAMServiceAccountCredentialsAPIErrorBody
27+
}
28+
29+
public struct IAMServiceAccountCredentialsAPIErrorBody: Codable {
30+
/// A container for the error details.
31+
public var status: String
32+
/// An HTTP status code value, without the textual description.
33+
public var code: Int
34+
/// Description of the error. Same as `errors.message`.
35+
public var message: String
36+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import Core
2+
import Foundation
3+
import NIO
4+
import NIOFoundationCompat
5+
import NIOHTTP1
6+
import AsyncHTTPClient
7+
8+
class IAMServiceAccountCredentialsRequest: GoogleCloudAPIRequest {
9+
10+
let refreshableToken: OAuthRefreshable
11+
let project: String
12+
let httpClient: HTTPClient
13+
let responseDecoder: JSONDecoder = JSONDecoder()
14+
var currentToken: OAuthAccessToken?
15+
var tokenCreatedTime: Date?
16+
var eventLoop: EventLoop
17+
18+
init(httpClient: HTTPClient, eventLoop: EventLoop, oauth: OAuthRefreshable, project: String) {
19+
self.refreshableToken = oauth
20+
self.httpClient = httpClient
21+
self.project = project
22+
self.eventLoop = eventLoop
23+
let dateFormatter = DateFormatter()
24+
25+
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
26+
self.responseDecoder.dateDecodingStrategy = .formatted(dateFormatter)
27+
}
28+
29+
public func send<GCM: GoogleCloudModel>(
30+
method: HTTPMethod,
31+
headers: HTTPHeaders = [:],
32+
path: String,
33+
query: String = "",
34+
body: HTTPClient.Body = .data(Data())
35+
) -> EventLoopFuture<GCM> {
36+
37+
return withToken { token in
38+
39+
return self._send(
40+
method: method,
41+
headers: headers,
42+
path: path,
43+
query: query,
44+
body: body,
45+
accessToken: token.accessToken
46+
).flatMap { response in
47+
do {
48+
let model = try self.responseDecoder.decode(GCM.self, from: response)
49+
return self.eventLoop.makeSucceededFuture(model)
50+
} catch {
51+
return self.eventLoop.makeFailedFuture(error)
52+
}
53+
}
54+
}
55+
}
56+
57+
private func _send(
58+
method: HTTPMethod,
59+
headers: HTTPHeaders,
60+
path: String,
61+
query: String,
62+
body: HTTPClient.Body,
63+
accessToken: String
64+
) -> EventLoopFuture<Data> {
65+
66+
var _headers: HTTPHeaders = ["Authorization": "Bearer \(accessToken)",
67+
"Content-Type": "application/json"]
68+
headers.forEach { _headers.replaceOrAdd(name: $0.name, value: $0.value) }
69+
70+
do {
71+
let request = try HTTPClient.Request(url: "\(path)?\(query)", method: method, headers: _headers, body: body)
72+
73+
return httpClient.execute(
74+
request: request,
75+
eventLoop: .delegate(on: self.eventLoop)
76+
).flatMap { response in
77+
78+
guard var byteBuffer = response.body else {
79+
fatalError("Response body from Google is missing! This should never happen.")
80+
}
81+
let responseData = byteBuffer.readData(length: byteBuffer.readableBytes)!
82+
83+
guard (200...299).contains(response.status.code) else {
84+
let error: Error
85+
if let jsonError = try? self.responseDecoder.decode(IAMServiceAccountCredentialsAPIError.self, from: responseData) {
86+
error = jsonError
87+
} else {
88+
let body = response.body?.getString(at: response.body?.readerIndex ?? 0, length: response.body?.readableBytes ?? 0) ?? ""
89+
error = IAMServiceAccountCredentialsAPIError(error: IAMServiceAccountCredentialsAPIErrorBody(status: "unknownError", code: Int(response.status.code), message: body))
90+
}
91+
92+
return self.eventLoop.makeFailedFuture(error)
93+
}
94+
return self.eventLoop.makeSucceededFuture(responseData)
95+
}
96+
} catch {
97+
return self.eventLoop.makeFailedFuture(error)
98+
}
99+
}
100+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Google Cloud IAM Service Account Credentials
2+
3+
## Using the IAM Service Account Credentials API
4+
5+
### Setting up IAMServiceAccountCredentialsConfiguration
6+
7+
To make GoogleCloudKit as flexible as possible to work with different API's and projects,
8+
you can configure each API with their own configuration if the default `GoogleCloudCredentialsConfiguration` doesn't satisfy your needs.
9+
10+
For example the `GoogleCloudCredentialsConfiguration` can be configured with a `ProjectID`, but you might
11+
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.
12+
To use the IAM Service Account Credentials API you can create a `GoogleCloudIAMServiceAccountCredentialsConfiguration` in one of 2 ways.
13+
14+
```swift
15+
let credentialsConfiguration = GoogleCloudCredentialsConfiguration(project: "my-project-1",
16+
credentialsFile: "/path/to/service-account.json")
17+
18+
let iamServiceAccountCredentialsConfiguration = IAMServiceAccountCredentialsConfiguration(scope: [.cloudPlatform],
19+
serviceAccount: "default",
20+
project: "my-project-2")
21+
// OR
22+
let iamServiceAccountCredentialsConfiguration = IAMServiceAccountCredentialsConfiguration.default()
23+
// has full control access and uses default service account with no project specified.
24+
```
25+
26+
### Now create an `IAMServiceAccountCredentialsClient` with the configuration and an `HTTPClient`
27+
```swift
28+
let client = HTTPClient(...)
29+
let iamClient = try IAMServiceAccountCredentialsClient(credentials: credentialsConfiguration,
30+
config: iamServiceAccountCredentialsConfiguration,
31+
httpClient: client,
32+
eventLoop: myEventLoop)
33+
34+
```
35+
The order of priority for which configured projectID the IAMServiceAccountCredentialsClient will use is as follows:
36+
1. `$GOOGLE_PROJECT_ID` environment variable.
37+
1. `$PROJECT_ID` environment variable.
38+
2. The Service Accounts projectID (Service account configured via the credentials path in the credentials configuration).
39+
3. `IAMServiceAccountCredentialsConfiguration`'s `project` property.
40+
4. `GoogleCloudCredentialsConfiguration`'s `project` property.
41+
42+
Initializing the client will throw an error if no projectID is set anywhere.
43+
44+
### Signing a JWT
45+
46+
```swift
47+
func signJWT() {
48+
let client = try IAMServiceAccountCredentialsClient(credentials: credentialsConfiguration,
49+
config: IAMServiceAccountCredentialsConfiguration,
50+
httpClient: client,
51+
eventLoop: myEventLoop)
52+
53+
let payload: JWTPayload = MyPayload(name: "key", value: "value")
54+
55+
client.api.signJWT(payload, serviceAccount: "[email protected]").map { response in
56+
print(response.signedJwt) // Prints JWT signed with the given service account's credentials
57+
}
58+
}
59+
```
60+
### What's implemented
61+
62+
#### IAM Service Account Credentials API
63+
* [x] signJWT
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Core
2+
import Foundation
3+
import JWTKit
4+
5+
public struct SignJWTRequest: GoogleCloudModel {
6+
7+
public init(jwt: JWTPayload, delegates: [String] = []) throws {
8+
9+
let encoder = JSONEncoder()
10+
encoder.dateEncodingStrategy = .integerSecondsSince1970
11+
12+
guard let data = try? encoder.encode(jwt) else {
13+
throw IAMServiceAccountCredentialsError.jwtEncodingFailed
14+
}
15+
16+
guard let payload = String(data: data, encoding: .utf8) else {
17+
throw IAMServiceAccountCredentialsError.jwtConversionFailed
18+
}
19+
20+
self.payload = payload
21+
self.delegates = delegates
22+
}
23+
24+
public let payload: String
25+
public let delegates: [String]
26+
}
27+
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import Core
2+
3+
public struct SignJWTResponse: GoogleCloudModel {
4+
5+
public let keyId: String
6+
public let signedJwt: String
7+
}

0 commit comments

Comments
 (0)