|
5 | 5 | // SPDX-License-Identifier: Apache-2.0 |
6 | 6 | // |
7 | 7 |
|
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 | + |
34 | 19 | 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 | + } |
48 | 49 | } |
| 50 | + return try await fetchCredentials(request: request) |
| 51 | + } |
49 | 52 |
|
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() |
62 | 62 | } 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." |
65 | 73 | ) |
66 | 74 | } |
| 75 | + return (resolvedURL, authToken) |
| 76 | + } |
67 | 77 |
|
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 |
77 | 84 | } |
78 | | -} |
79 | 85 |
|
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 | + } |
84 | 99 | } |
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"] |
87 | 103 | } |
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 | + } |
92 | 143 | } |
93 | | - return (hostComponent, pathQueryFragment.absoluteString) |
94 | | -} |
95 | 144 |
|
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 |
99 | 150 | } |
100 | | - return true |
101 | | -} |
102 | 151 |
|
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. |
109 | 154 | 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 | + ) |
114 | 176 | } 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 | + ) |
116 | 182 | } |
117 | 183 | } |
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") |
121 | 184 | } |
122 | 185 |
|
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) |
125 | 208 |
|
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 | + } |
128 | 244 | } |
129 | 245 | } |
0 commit comments