Skip to content

Commit 1274278

Browse files
sichanyooSichan Yoo
andauthored
feat: ECS credentials resolver (#1945)
* Add rough draft implementation of ECSAWSCredentialIdentityResolver. * Fix guard condition for valid HTTP URL. * Misc. changes (comments, condition clarification) * Swiftlint * Add urlSession property and initializer; refactor unit tests to use mocked URLSession. * Address remaining comments from David. * Break down resolveAuthToken functinos into subfunctions for readability. * Import FoundationNetworking for ECS resolver unit tests for Linux. * Add workaround for URLSession::data function not being available in Linux. * Add temporary workaround for ProcessAWSCredentialIdentityResolver CRT bug. * Remove temporary test print stmt & fix workaround. * Add exponential backoff to sending request to link-local / container credential endpoint. --------- Co-authored-by: Sichan Yoo <[email protected]>
1 parent 9a6685c commit 1274278

File tree

5 files changed

+362
-169
lines changed

5 files changed

+362
-169
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
public enum AWSCredentialIdentityResolverError: Error {
9+
case failedToResolveAWSCredentials(_ message: String)
10+
}

Sources/Core/AWSSDKIdentity/Sources/AWSSDKIdentity/AWSCredentialIdentityResolvers/DefaultAWSCredentialIdentityResolverChain.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public actor DefaultAWSCredentialIdentityResolverChain: AWSCredentialIdentityRes
3939
{ return (try EnvironmentAWSCredentialIdentityResolver()) },
4040
{ return (try ProfileAWSCredentialIdentityResolver()) },
4141
{ return (try STSWebIdentityAWSCredentialIdentityResolver()) },
42-
{ return (try ECSAWSCredentialIdentityResolver()) },
42+
{ return (ECSAWSCredentialIdentityResolver()) },
4343
{ return (try IMDSAWSCredentialIdentityResolver()) }
4444
]
4545
}

Sources/Core/AWSSDKIdentity/Sources/AWSSDKIdentity/AWSCredentialIdentityResolvers/ECSAWSCredentialIdentityResolver.swift

Lines changed: 214 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -5,125 +5,241 @@
55
// SPDX-License-Identifier: Apache-2.0
66
//
77

8-
import class AwsCommonRuntimeKit.CredentialsProvider
9-
import ClientRuntime
10-
import class Foundation.ProcessInfo
11-
import enum Smithy.ClientError
12-
import enum SmithyHTTPAPI.HTTPClientError
13-
import protocol SmithyIdentity.AWSCredentialIdentityResolvedByCRT
14-
import struct Foundation.URL
15-
import struct Foundation.URLComponents
16-
17-
/// A credential identity resolver that sources credentials from ECS container metadata
18-
public struct ECSAWSCredentialIdentityResolver: AWSCredentialIdentityResolvedByCRT {
19-
public let crtAWSCredentialIdentityResolver: AwsCommonRuntimeKit.CredentialsProvider
20-
public let resolvedHost: String
21-
public let resolvedPathAndQuery: String
22-
public let resolvedAuthorizationToken: String?
23-
24-
/// Creates a credential identity resolver that resolves credentials from ECS container metadata.
25-
/// ECS creds provider can be used to access creds via either relative uri to a fixed endpoint http://169.254.170.2,
26-
/// or via a full uri specified by environment variables:
27-
/// - AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
28-
/// - AWS_CONTAINER_CREDENTIALS_FULL_URI
29-
/// - AWS_CONTAINER_AUTHORIZATION_TOKEN
30-
///
31-
/// If both relative uri and absolute uri are set, relative uri has higher priority.
32-
/// Token is used in auth header but only for absolute uri.
33-
/// - Throws: CommonRuntimeError.crtError or InitializationError.missingURIs
8+
import Foundation
9+
import protocol SmithyIdentity.AWSCredentialIdentityResolver
10+
import struct Smithy.Attributes
11+
#if os(Linux)
12+
import FoundationNetworking // For URLSession in Linux.
13+
#endif
14+
15+
public struct ECSAWSCredentialIdentityResolver: AWSCredentialIdentityResolver {
16+
private let urlSession: URLSession
17+
private let maxRetries: Int
18+
3419
public init(
35-
relativeURI: String? = nil,
36-
absoluteURI: String? = nil,
37-
authorizationToken: String? = nil
38-
) throws {
39-
let env = ProcessEnvironment()
40-
41-
let resolvedRelativeURI = relativeURI ?? env.environmentVariable(key: "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")
42-
let resolvedAbsoluteURI = absoluteURI ?? env.environmentVariable(key: "AWS_CONTAINER_CREDENTIALS_FULL_URI")
43-
44-
guard resolvedRelativeURI != nil || isValidAbsoluteURI(resolvedAbsoluteURI) else {
45-
throw ClientError.dataNotFound(
46-
"Please configure either the relative or absolute URI environment variable!"
47-
)
20+
urlSession: URLSession? = nil,
21+
maxRetries: Int = 3
22+
) {
23+
self.urlSession = urlSession ?? URLSession.shared
24+
self.maxRetries = maxRetries
25+
}
26+
27+
public func getIdentity(identityProperties: Attributes?) async throws -> AWSCredentialIdentity {
28+
// 1. Get URI configured via environment variables.
29+
let (resolvedURL, authToken) = try resolveURLAndOptionalAuthToken()
30+
31+
// 2. Validate resolved URL before proceeding.
32+
try validateResolvedURL(resolvedURL)
33+
34+
// 3. Create URLRequest; add Authorization header if applicable.
35+
var request = URLRequest(url: resolvedURL)
36+
if let authToken {
37+
request.addValue(authToken, forHTTPHeaderField: "Authorization")
38+
}
39+
40+
// 4. Send the URLRequest; retry 3 times max.
41+
var backoff: TimeInterval = 0.1
42+
for _ in 0..<maxRetries {
43+
do {
44+
return try await fetchCredentials(request: request)
45+
} catch {
46+
try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000))
47+
backoff *= 2
48+
}
4849
}
50+
return try await fetchCredentials(request: request)
51+
}
4952

50-
let defaultHost = "169.254.170.2"
51-
var host = defaultHost
52-
var pathAndQuery = resolvedRelativeURI ?? ""
53-
var resolvedAuthToken: String?
54-
55-
if let relative = resolvedRelativeURI {
56-
pathAndQuery = relative
57-
} else if let absolute = resolvedAbsoluteURI, let absoluteURL = URL(string: absolute) {
58-
let (absoluteHost, absolutePathAndQuery) = try retrieveHostPathAndQuery(from: absoluteURL)
59-
host = absoluteHost
60-
pathAndQuery = absolutePathAndQuery
61-
resolvedAuthToken = try resolveToken(authorizationToken, env)
53+
private func resolveURLAndOptionalAuthToken() throws -> (URL, String?) {
54+
var resolvedURI: String
55+
var authToken: String?
56+
57+
if let relativeURI = ProcessInfo.processInfo.environment["AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"] {
58+
resolvedURI = "http://169.254.170.2" + relativeURI // Prefix it with ECS container host.
59+
} else if let fullURI = ProcessInfo.processInfo.environment["AWS_CONTAINER_CREDENTIALS_FULL_URI"] {
60+
resolvedURI = fullURI
61+
authToken = try resolveAuthToken()
6262
} else {
63-
throw HTTPClientError.pathCreationFailed(
64-
"Failed to retrieve either relative or absolute URI! URI may be malformed."
63+
throw AWSCredentialIdentityResolverError.failedToResolveAWSCredentials(
64+
"ECSAWSCredentialIdentityResolver: Couldn't initialize. "
65+
+ "Neither AWS_CONTAINER_CREDENTIALS_RELATIVE_URI nor "
66+
+ "AWS_CONTAINER_CREDENTIALS_FULL_URI environment variables were set."
67+
)
68+
}
69+
guard let resolvedURL = URL(string: resolvedURI) else {
70+
throw AWSCredentialIdentityResolverError.failedToResolveAWSCredentials(
71+
"ECSAWSCredentialIdentityResolver: "
72+
+ "Could not create URL from resolved URI."
6573
)
6674
}
75+
return (resolvedURL, authToken)
76+
}
6777

68-
self.resolvedHost = host
69-
self.resolvedPathAndQuery = pathAndQuery
70-
self.resolvedAuthorizationToken = resolvedAuthToken
71-
self.crtAWSCredentialIdentityResolver = try AwsCommonRuntimeKit.CredentialsProvider(source: .ecs(
72-
bootstrap: SDKDefaultIO.shared.clientBootstrap,
73-
authToken: resolvedAuthToken,
74-
pathAndQuery: pathAndQuery,
75-
host: host
76-
))
78+
private func resolveAuthToken() throws -> String? {
79+
if let token = try readTokenFromFile() ?? readTokenFromEnvironment() {
80+
try validateToken(token)
81+
return token
82+
}
83+
return nil
7784
}
78-
}
7985

80-
private func retrieveHostPathAndQuery(from url: URL) throws -> (String, String) {
81-
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
82-
let message = "Absolute URI is malformed! Could not instantiate URLComponents from URL."
83-
throw HTTPClientError.pathCreationFailed(message)
86+
private func readTokenFromFile() throws -> String? {
87+
guard let tokenFilePath = ProcessInfo.processInfo.environment["AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE"] else {
88+
return nil
89+
}
90+
do {
91+
let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: tokenFilePath))
92+
let data = fileHandle.readDataToEndOfFile()
93+
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .newlines)
94+
} catch {
95+
throw AWSCredentialIdentityResolverError.failedToResolveAWSCredentials(
96+
"ECSAWSCredentialIdentityResolver: Failed to read authorization token file from configured path."
97+
)
98+
}
8499
}
85-
guard let hostComponent = components.host else {
86-
throw HTTPClientError.pathCreationFailed("Absolute URI is malformed! Could not retrieve host from URL.")
100+
101+
private func readTokenFromEnvironment() -> String? {
102+
ProcessInfo.processInfo.environment["AWS_CONTAINER_AUTHORIZATION_TOKEN"]
87103
}
88-
components.scheme = nil
89-
components.host = nil
90-
guard let pathQueryFragment = components.url else {
91-
throw HTTPClientError.pathCreationFailed("Could not retrieve path from URL!")
104+
105+
private func validateToken(_ token: String) throws {
106+
guard !token.contains(where: { $0.isNewline }) else {
107+
throw AWSCredentialIdentityResolverError.failedToResolveAWSCredentials(
108+
"ECSAWSCredentialIdentityResolver: Resolved auth token contains a newline character, making it invalid."
109+
)
110+
}
111+
}
112+
113+
private func validateResolvedURL(_ resolvedURL: URL) throws {
114+
guard let host = resolvedURL.host else {
115+
throw AWSCredentialIdentityResolverError.failedToResolveAWSCredentials(
116+
"ECSAWSCredentialIdentityResolver: "
117+
+ "Resolved URL does not have a host."
118+
)
119+
}
120+
121+
// Must fail if host isn't IP address & scheme isn't HTTPS.
122+
if resolvedURL.scheme != "https" && !isIPv4Address(host) {
123+
throw AWSCredentialIdentityResolverError.failedToResolveAWSCredentials(
124+
"ECSAWSCredentialIdentityResolver: "
125+
+ "Resolved URL has HTTP scheme with host that isn't an IP address."
126+
)
127+
} else if !isIPv4Address(host) {
128+
return
129+
}
130+
131+
// Must fail if host is IP address and doesn't match one of the three conditions below.
132+
guard host == "169.254.170.2" // ECS container host.
133+
|| host == "169.254.170.23" // EKS container host.
134+
|| host.split(separator: ".").first == "127" else { // Loopback CIDR.
135+
throw AWSCredentialIdentityResolverError.failedToResolveAWSCredentials(
136+
"ECSAWSCredentialIdentityResolver: "
137+
+ "The IP address in resolved URL is invalid. "
138+
+ "It must be within loopback CIDR 127.0.0.0/8, "
139+
+ "or be the ECS container host 169.254.170.2, "
140+
+ "or be the EKS container host 169.254.170.23."
141+
)
142+
}
92143
}
93-
return (hostComponent, pathQueryFragment.absoluteString)
94-
}
95144

96-
private func isValidAbsoluteURI(_ uri: String?) -> Bool {
97-
guard let validUri = uri, URL(string: validUri)?.host != nil else {
98-
return false
145+
private func isIPv4Address(_ host: String) -> Bool {
146+
// Regex pattern that checks string of pattern X.X.X.X where X is an integer between 0 and 255.
147+
let pattern = "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}"
148+
+ "([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$"
149+
return host.range(of: pattern, options: .regularExpression) != nil
99150
}
100-
return true
101-
}
102151

103-
private func resolveToken(_ authorizationToken: String?, _ env: ProcessEnvironment) throws -> String? {
104-
// Initialize token variable
105-
var tokenFromFile: String?
106-
if let tokenPath = env.environmentVariable(
107-
key: "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE"
108-
) {
152+
private func fetchCredentials(request: URLRequest) async throws -> AWSCredentialIdentity {
153+
// If status code is 200, parse response payload into AWS credentials and return it.
109154
do {
110-
// Load the token from the file
111-
let tokenFilePath = URL(fileURLWithPath: tokenPath)
112-
tokenFromFile = try String(contentsOf: tokenFilePath, encoding: .utf8)
113-
.trimmingCharacters(in: .whitespacesAndNewlines)
155+
let (data, response) = try await urlSession.asyncData(for: request)
156+
157+
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
158+
throw AWSCredentialIdentityResolverError.failedToResolveAWSCredentials(
159+
"ECSAWSCredentialIdentityResolver: "
160+
+ "Could not retrieve credentials from resolved URL."
161+
)
162+
}
163+
164+
// Parse response into AWS credentials and return it.
165+
let jsonCredentialResponse = try JSONDecoder().decode(
166+
JSONCredentialResponse.self,
167+
from: data
168+
)
169+
return AWSCredentialIdentity(
170+
accessKey: jsonCredentialResponse.accessKeyID,
171+
secret: jsonCredentialResponse.secretAccessKey,
172+
accountID: jsonCredentialResponse.accountID,
173+
expiration: jsonCredentialResponse.expiration,
174+
sessionToken: jsonCredentialResponse.sessionToken
175+
)
114176
} catch {
115-
throw ClientError.dataNotFound("Error reading the token file: \(error)")
177+
// Handle network errors (not HTTP status errors).
178+
throw AWSCredentialIdentityResolverError.failedToResolveAWSCredentials(
179+
"ECSAWSCredentialIdentityResolver: "
180+
+ "Failed to retrieve credentials: \(error)"
181+
)
116182
}
117183
}
118-
119-
// AWS_CONTAINER_AUTHORIZATION_TOKEN should only be used if AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE is not set
120-
return authorizationToken ?? tokenFromFile ?? env.environmentVariable(key: "AWS_CONTAINER_AUTHORIZATION_TOKEN")
121184
}
122185

123-
private struct ProcessEnvironment {
124-
public init() {}
186+
// Serde utility for decoding JSON credential response from HTTP endpoint.
187+
private struct JSONCredentialResponse: Codable {
188+
let accessKeyID: String
189+
let secretAccessKey: String
190+
let sessionToken: String?
191+
let expiration: Date?
192+
let accountID: String?
193+
194+
enum CodingKeys: String, CodingKey {
195+
case accessKeyID = "AccessKeyId"
196+
case secretAccessKey = "SecretAccessKey"
197+
case sessionToken = "Token"
198+
case expiration = "Expiration"
199+
case accountID = "AccountId"
200+
}
201+
202+
init(from decoder: Decoder) throws {
203+
let container = try decoder.container(keyedBy: CodingKeys.self)
204+
205+
// Required fields
206+
accessKeyID = try container.decode(String.self, forKey: .accessKeyID)
207+
secretAccessKey = try container.decode(String.self, forKey: .secretAccessKey)
125208

126-
public func environmentVariable(key: String) -> String? {
127-
return ProcessInfo.processInfo.environment[key]
209+
// Optional fields
210+
sessionToken = try container.decodeIfPresent(String.self, forKey: .sessionToken)
211+
accountID = try container.decodeIfPresent(String.self, forKey: .accountID)
212+
213+
// Handle the Expiration field which is a string in ISO8601 format.
214+
if let expirationString = try container.decodeIfPresent(String.self, forKey: .expiration) {
215+
let formatter = ISO8601DateFormatter()
216+
expiration = formatter.date(from: expirationString)
217+
} else {
218+
expiration = nil
219+
}
220+
}
221+
}
222+
223+
// URLSession.data(for:) isn't available in Linux; so this wrapper is used instead.
224+
extension URLSession {
225+
func asyncData(for request: URLRequest) async throws -> (Data, URLResponse) {
226+
return try await withCheckedThrowingContinuation { continuation in
227+
let task = self.dataTask(with: request) { data, response, error in
228+
if let error = error {
229+
continuation.resume(throwing: error)
230+
return
231+
}
232+
guard let data = data, let response = response else {
233+
continuation.resume(throwing: NSError(
234+
domain: "URLSession",
235+
code: 0,
236+
userInfo: [NSLocalizedDescriptionKey: "No data or response returned"]
237+
))
238+
return
239+
}
240+
continuation.resume(returning: (data, response))
241+
}
242+
task.resume()
243+
}
128244
}
129245
}

0 commit comments

Comments
 (0)