Skip to content

Commit eded477

Browse files
committed
add support for impersonated service accounts
1 parent aba2423 commit eded477

File tree

5 files changed

+168
-4
lines changed

5 files changed

+168
-4
lines changed

Core/Sources/Configuration/Credentials/GoogleCloudCredentials.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,43 @@ public struct GoogleServiceAccountCredentials: Codable {
7171
}
7272
}
7373

74+
public struct SourceCredentials: Codable {
75+
public let clientId: String
76+
public let clientSecret: String
77+
public let refreshToken: String
78+
public let type: String
79+
}
80+
81+
public struct ImpersonatedServiceAccountCredentials: Codable {
82+
public let delegates: [String]
83+
public let serviceAccountImpersonationUrl: String
84+
public let sourceCredentials: SourceCredentials
85+
public let type: String
86+
87+
// Initializer to load from a JSON string
88+
public init(fromJsonString json: String) throws {
89+
let decoder = JSONDecoder()
90+
decoder.keyDecodingStrategy = .convertFromSnakeCase
91+
if let data = json.data(using: .utf8) {
92+
self = try decoder.decode(ImpersonatedServiceAccountCredentials.self, from: data)
93+
} else {
94+
throw CredentialLoadError.jsonLoadError
95+
}
96+
}
97+
98+
// Initializer to load from a file path
99+
public init(fromFilePath path: String) throws {
100+
let decoder = JSONDecoder()
101+
decoder.keyDecodingStrategy = .convertFromSnakeCase
102+
if let contents = try String(contentsOfFile: path).data(using: .utf8) {
103+
self = try decoder.decode(ImpersonatedServiceAccountCredentials.self, from: contents)
104+
} else {
105+
throw CredentialLoadError.fileLoadError(path)
106+
}
107+
}
108+
}
109+
110+
74111
public class OAuthCredentialLoader {
75112
public static func getRefreshableToken(credentials: GoogleCloudCredentialsConfiguration,
76113
withConfig config: GoogleCloudAPIConfiguration,
@@ -86,6 +123,13 @@ public class OAuthCredentialLoader {
86123
eventLoop: eventLoop)
87124
}
88125

126+
if let impersonatedServiceAccount = credentials.impersonatedServiceAccountCredentials {
127+
return OAuthImpersonatedServiceAccount(credentials: impersonatedServiceAccount,
128+
scopes: config.scope,
129+
httpClient: client,
130+
eventLoop: eventLoop)
131+
}
132+
89133
// Check Default application credentials next.
90134
if let appDefaultCredentials = credentials.applicationDefaultCredentials {
91135
return OAuthApplicationDefault(credentials: appDefaultCredentials,

Core/Sources/Configuration/GoogleCloudCredentialsConfiguration.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Foundation
1010
public struct GoogleCloudCredentialsConfiguration {
1111
public let project: String?
1212
public var serviceAccountCredentials: GoogleServiceAccountCredentials?
13+
public var impersonatedServiceAccountCredentials: ImpersonatedServiceAccountCredentials?
1314
public var applicationDefaultCredentials: GoogleApplicationDefaultCredentials?
1415

1516
public init(projectId: String? = nil, credentialsFile: String? = nil) throws {
@@ -32,6 +33,12 @@ public struct GoogleCloudCredentialsConfiguration {
3233
self.serviceAccountCredentials = try? GoogleServiceAccountCredentials(fromJsonString: serviceAccountCredentialsPath)
3334
}
3435

36+
if let impersonatedServiceaccount = try? ImpersonatedServiceAccountCredentials(fromFilePath: serviceAccountCredentialsPath) {
37+
self.impersonatedServiceAccountCredentials = impersonatedServiceaccount
38+
} else {
39+
self.impersonatedServiceAccountCredentials = try? ImpersonatedServiceAccountCredentials(fromJsonString: serviceAccountCredentialsPath)
40+
}
41+
3542
if let defaultcredentials = try? GoogleApplicationDefaultCredentials(fromFilePath: serviceAccountCredentialsPath) {
3643
self.applicationDefaultCredentials = defaultcredentials
3744
} else {

Core/Sources/Configuration/GoogleCloudError.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,19 @@ enum CredentialLoadError: GoogleCloudError {
2525

2626
enum OauthRefreshError: GoogleCloudError {
2727
case noResponse(HTTPResponseStatus)
28+
case invalidExpiryDate
2829

2930
var localizedDescription: String {
3031
switch self {
3132
case .noResponse(let status):
3233
return "A request to the OAuth authorization server failed with response status \(status.code)."
34+
case .invalidExpiryDate:
35+
return "Authorization server returned an invalid expiry"
3336
}
3437
}
3538
}
3639

3740

38-
3941
public struct GoogleCloudAPIErrorMain: GoogleCloudError, GoogleCloudModel {
4042
/// A container for the error information.
4143
public var error: GoogleCloudAPIErrorBody

Core/Sources/Configuration/OAuth/OAuthApplicationDefault.swift

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,26 @@ import Foundation
1313

1414
public class OAuthApplicationDefault: OAuthRefreshable {
1515
let httpClient: HTTPClient
16-
let credentials: GoogleApplicationDefaultCredentials
16+
let clientId: String
17+
let clientSecret: String
18+
let refreshToken: String
19+
1720
private let decoder = JSONDecoder()
1821
private let eventLoop: EventLoop
1922

2023
init(credentials: GoogleApplicationDefaultCredentials, httpClient: HTTPClient, eventLoop: EventLoop) {
21-
self.credentials = credentials
24+
self.clientId = credentials.clientId
25+
self.clientSecret = credentials.clientSecret
26+
self.refreshToken = credentials.refreshToken
27+
self.httpClient = httpClient
28+
self.eventLoop = eventLoop
29+
decoder.keyDecodingStrategy = .convertFromSnakeCase
30+
}
31+
32+
init(clientId: String, clientSecret:String, refreshToken:String, httpClient: HTTPClient, eventLoop: EventLoop) {
33+
self.clientId = clientId
34+
self.clientSecret = clientSecret
35+
self.refreshToken = refreshToken
2236
self.httpClient = httpClient
2337
self.eventLoop = eventLoop
2438
decoder.keyDecodingStrategy = .convertFromSnakeCase
@@ -29,7 +43,7 @@ public class OAuthApplicationDefault: OAuthRefreshable {
2943
do {
3044
let headers: HTTPHeaders = ["Content-Type": "application/x-www-form-urlencoded"]
3145

32-
let body: HTTPClient.Body = .string("client_id=\(credentials.clientId)&client_secret=\(credentials.clientSecret)&refresh_token=\(credentials.refreshToken)&grant_type=refresh_token")
46+
let body: HTTPClient.Body = .string("client_id=\(clientId)&client_secret=\(clientSecret)&refresh_token=\(refreshToken)&grant_type=refresh_token")
3347

3448
let request = try HTTPClient.Request(url: GoogleOAuthTokenUrl, method: .POST, headers: headers, body: body)
3549

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import Foundation
2+
import NIOHTTP1
3+
import AsyncHTTPClient
4+
import Foundation
5+
import NIO
6+
7+
struct ImpersonateTokenResponse: Decodable {
8+
let accessToken: String
9+
let expireTime: String
10+
}
11+
12+
public class OAuthImpersonatedServiceAccount: OAuthRefreshable {
13+
let httpClient: HTTPClient
14+
let credentials: ImpersonatedServiceAccountCredentials
15+
let tokenLifetimeSeconds: Int?
16+
public let scope: String
17+
private let decoder = JSONDecoder()
18+
private let eventLoop: EventLoop
19+
20+
init(credentials: ImpersonatedServiceAccountCredentials, scopes: [GoogleCloudAPIScope], httpClient: HTTPClient, eventLoop: EventLoop, tokenLifetimeSeconds: Int? = nil) {
21+
self.credentials = credentials
22+
self.scope = scopes.map { $0.value }.joined(separator: " ")
23+
self.httpClient = httpClient
24+
self.eventLoop = eventLoop
25+
self.tokenLifetimeSeconds = tokenLifetimeSeconds
26+
decoder.keyDecodingStrategy = .convertFromSnakeCase
27+
}
28+
29+
public func refresh() -> EventLoopFuture<OAuthAccessToken> {
30+
31+
let tokenRefresher = OAuthApplicationDefault(
32+
clientId: credentials.sourceCredentials.clientId,
33+
clientSecret: credentials.sourceCredentials.clientSecret,
34+
refreshToken: credentials.sourceCredentials.refreshToken,
35+
httpClient: httpClient,
36+
eventLoop: eventLoop)
37+
38+
// Use flatMap to handle the future returned by tokenRefresher.refresh()
39+
return tokenRefresher.refresh().flatMap { accessToken in
40+
do {
41+
// Construct the request body
42+
let lifetimeString = "\(self.tokenLifetimeSeconds ?? 3600)s"
43+
let requestBody: [String: Any] = [
44+
"lifetime": lifetimeString,
45+
"scope": self.scope,
46+
"delegates": self.credentials.delegates
47+
]
48+
49+
let requestBodyData = try JSONSerialization.data(withJSONObject: requestBody, options: [])
50+
51+
// Prepare the HTTP request
52+
let authToken = "Bearer \(accessToken.accessToken)"
53+
let headers: HTTPHeaders = ["Content-Type": "application/json", "Authorization": authToken]
54+
55+
let request = try HTTPClient.Request(
56+
url: self.credentials.serviceAccountImpersonationUrl,
57+
method: .POST,
58+
headers: headers,
59+
body: .data(requestBodyData)
60+
)
61+
62+
// Execute the request
63+
return self.httpClient.execute(request: request, eventLoop: .delegate(on: self.eventLoop)).flatMap { response in
64+
65+
guard var byteBuffer = response.body,
66+
let responseData = byteBuffer.readData(length: byteBuffer.readableBytes),
67+
response.status == .ok else {
68+
return self.eventLoop.makeFailedFuture(OauthRefreshError.noResponse(response.status))
69+
}
70+
71+
do {
72+
let accessTokenResponse = try self.decoder.decode(ImpersonateTokenResponse.self, from: responseData)
73+
// Parse expiry time
74+
guard let expiry = ISO8601DateFormatter().date(from: accessTokenResponse.expireTime) else {
75+
return self.eventLoop.makeFailedFuture(OauthRefreshError.invalidExpiryDate)
76+
}
77+
let currentDate = Date()
78+
let expiresIn = Int(expiry.timeIntervalSince(currentDate))
79+
80+
let oauthAccessToken = OAuthAccessToken(
81+
accessToken: accessTokenResponse.accessToken,
82+
tokenType: "Bearer",
83+
expiresIn: expiresIn
84+
)
85+
86+
return self.eventLoop.makeSucceededFuture(oauthAccessToken)
87+
} catch {
88+
return self.eventLoop.makeFailedFuture(error)
89+
}
90+
}
91+
} catch {
92+
return self.eventLoop.makeFailedFuture(error)
93+
}
94+
}
95+
}
96+
97+
}

0 commit comments

Comments
 (0)