|
5 | 5 |
|
6 | 6 | package aws.sdk.kotlin.runtime.auth.credentials |
7 | 7 |
|
8 | | -import aws.sdk.kotlin.runtime.auth.credentials.internal.credentials |
9 | | -import aws.sdk.kotlin.runtime.config.imds.* |
| 8 | +import aws.sdk.kotlin.runtime.config.AwsSdkSetting |
| 9 | +import aws.sdk.kotlin.runtime.config.imds.EC2MetadataError |
| 10 | +import aws.sdk.kotlin.runtime.config.imds.ImdsClient |
| 11 | +import aws.sdk.kotlin.runtime.config.imds.InstanceMetadataProvider |
10 | 12 | import aws.sdk.kotlin.runtime.http.interceptors.businessmetrics.AwsBusinessMetric |
11 | 13 | import aws.sdk.kotlin.runtime.http.interceptors.businessmetrics.withBusinessMetric |
12 | 14 | import aws.smithy.kotlin.runtime.auth.awscredentials.* |
13 | 15 | import aws.smithy.kotlin.runtime.collections.Attributes |
| 16 | +import aws.smithy.kotlin.runtime.config.resolve |
14 | 17 | import aws.smithy.kotlin.runtime.http.HttpStatusCode |
15 | 18 | import aws.smithy.kotlin.runtime.io.IOException |
16 | 19 | import aws.smithy.kotlin.runtime.serde.json.JsonDeserializer |
17 | 20 | import aws.smithy.kotlin.runtime.telemetry.logging.info |
| 21 | +import aws.smithy.kotlin.runtime.telemetry.logging.warn |
18 | 22 | import aws.smithy.kotlin.runtime.time.Clock |
| 23 | +import aws.smithy.kotlin.runtime.time.Instant |
19 | 24 | import aws.smithy.kotlin.runtime.util.PlatformEnvironProvider |
20 | 25 | import aws.smithy.kotlin.runtime.util.PlatformProvider |
21 | | -import aws.smithy.kotlin.runtime.util.SingleFlightGroup |
22 | | -import aws.smithy.kotlin.runtime.util.asyncLazy |
| 26 | +import kotlinx.coroutines.sync.Mutex |
| 27 | +import kotlinx.coroutines.sync.withLock |
23 | 28 | import kotlin.coroutines.coroutineContext |
| 29 | +import kotlin.time.Duration.Companion.seconds |
24 | 30 |
|
| 31 | +private const val CREDENTIALS_BASE_PATH: String = "/latest/meta-data/iam/security-credentials/" |
25 | 32 | private const val CODE_ASSUME_ROLE_UNAUTHORIZED_ACCESS: String = "AssumeRoleUnauthorizedAccess" |
26 | 33 | private const val PROVIDER_NAME = "IMDSv2" |
27 | 34 |
|
28 | 35 | /** |
29 | 36 | * [CredentialsProvider] that uses EC2 instance metadata service (IMDS) to provide credentials information. |
30 | | - * This provider requires that the EC2 instance has an |
31 | | - * [instance profile](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#ec2-instance-profile) |
| 37 | + * This provider requires that the EC2 instance has an [instance profile](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#ec2-instance-profile) |
32 | 38 | * configured. |
33 | | - * @param instanceProfileName overrides the instance profile name. When set, this provider skips querying IMDS for the |
34 | | - * name of the active profile. |
35 | | - * @param client a preconfigured IMDS client with which to retrieve instance metadata. If an instance is passed, the |
36 | | - * caller is responsible for closing it. If no instance is passed, a default instance is created and will be closed when |
37 | | - * this credentials provider is closed. |
38 | | - * @param platformProvider a platform provider used for env vars and system properties |
| 39 | + * |
| 40 | + * See [EC2 IAM Roles](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html) for more |
| 41 | + * information. |
| 42 | + * |
| 43 | + * @param profileOverride override the instance profile name. When retrieving credentials, a call must first be made to |
| 44 | + * `<IMDS_BASE_URL>/latest/meta-data/iam/security-credentials/`. This returns the instance profile used. If |
| 45 | + * [profileOverride] is set, the initial call to retrieve the profile is skipped and the provided value is used instead. |
| 46 | + * @param client the IMDS client to use to resolve credentials information with. This provider takes ownership over |
| 47 | + * the lifetime of the given [ImdsClient] and will close it when the provider is closed. |
| 48 | + * @param platformProvider the [PlatformEnvironProvider] instance |
39 | 49 | */ |
40 | 50 | public class ImdsCredentialsProvider( |
41 | | - instanceProfileName: String? = null, |
42 | | - client: InstanceMetadataProvider? = null, |
43 | | - platformProvider: PlatformProvider = PlatformProvider.System, |
| 51 | + public val profileOverride: String? = null, |
| 52 | + public val client: Lazy<InstanceMetadataProvider> = lazy { ImdsClient() }, |
| 53 | + public val platformProvider: PlatformEnvironProvider = PlatformProvider.System, |
| 54 | + private val clock: Clock = Clock.System, |
44 | 55 | ) : CloseableCredentialsProvider { |
45 | | - |
46 | | - @Deprecated("This constructor supports parameters which are no longer used in the implementation. It will be removed in version 1.5.") |
47 | | - public constructor( |
48 | | - profileOverride: String? = null, |
49 | | - client: Lazy<InstanceMetadataProvider> = lazy { ImdsClient() }, |
50 | | - platformProvider: PlatformEnvironProvider = PlatformProvider.System, |
51 | | - @Suppress("UNUSED_PARAMETER") clock: Clock = Clock.System, |
52 | | - ) : this( |
53 | | - profileOverride, |
54 | | - client.value, |
55 | | - platformProvider = platformProvider as? PlatformProvider ?: PlatformProvider.System, |
56 | | - ) |
57 | | - |
58 | | - private val actualPlatformProvider = platformProvider |
59 | | - |
60 | | - @Deprecated("This property is retained for backwards compatibility but no longer needs to be public and will be removed in version 1.5.") |
61 | | - public val platformProvider: PlatformEnvironProvider = actualPlatformProvider |
62 | | - |
63 | | - private val manageClient: Boolean = client == null |
64 | | - |
65 | | - private val actualClient = client ?: ImdsClient { |
66 | | - this.platformProvider = actualPlatformProvider |
67 | | - } |
68 | | - |
69 | | - @Deprecated("This property is retained for backwards compatibility but no longer needs to be public and will be removed in version 1.5.") |
70 | | - public val client: Lazy<InstanceMetadataProvider> |
71 | | - get() = lazyOf(actualClient) |
72 | | - |
73 | | - private val instanceProfileName = asyncLazy { |
74 | | - instanceProfileName ?: resolveEc2InstanceProfileName(platformProvider) |
75 | | - } |
76 | | - |
77 | | - @Deprecated("This property is retained for backwards compatibility but no longer needs to be public and will be removed in version 1.5.") |
78 | | - public val profileOverride: String? = instanceProfileName |
79 | | - |
80 | | - private val providerDisabled = asyncLazy { |
81 | | - resolveDisableEc2Metadata(platformProvider) ?: false |
82 | | - } |
83 | | - |
84 | | - /** |
85 | | - * Tracks the known-good version of IMDS APIs available in the local environment. This starts as `null` and will be |
86 | | - * updated after the first successful API call. |
87 | | - */ |
88 | | - private var apiVersion: ApiVersion? = null |
89 | | - |
90 | | - private val urlBase: String |
91 | | - get() = (apiVersion ?: ApiVersion.EXTENDED).urlBase |
92 | | - |
93 | 56 | private var previousCredentials: Credentials? = null |
94 | 57 |
|
95 | | - /** |
96 | | - * Tracks the instance profile name resolved from IMDS. This starts as `null` and will be updated after a |
97 | | - * successful API call. Note that if [instanceProfileName] is set, profile name resolution will be skipped. |
98 | | - */ |
99 | | - private var resolvedProfileName: String? = null |
100 | | - |
101 | | - /** |
102 | | - * A deduplicator for resolving credentials and tracking mutable state about IMDS |
103 | | - */ |
104 | | - private val sfg = SingleFlightGroup<Credentials>() |
| 58 | + // the time to refresh the Credentials. If set, it will take precedence over the Credentials' expiration time |
| 59 | + private var nextRefresh: Instant? = null |
105 | 60 |
|
106 | | - override suspend fun resolve(attributes: Attributes): Credentials = sfg.singleFlight(::resolveSingleFlight) |
| 61 | + // protects previousCredentials and nextRefresh |
| 62 | + private val mu = Mutex() |
107 | 63 |
|
108 | | - private suspend fun resolveSingleFlight(): Credentials { |
109 | | - if (providerDisabled.get()) { |
| 64 | + override suspend fun resolve(attributes: Attributes): Credentials { |
| 65 | + if (AwsSdkSetting.AwsEc2MetadataDisabled.resolve(platformProvider) == true) { |
110 | 66 | throw CredentialsNotLoadedException("AWS EC2 metadata is explicitly disabled; credentials not loaded") |
111 | 67 | } |
112 | 68 |
|
113 | | - val profileName = instanceProfileName.get() ?: resolvedProfileName ?: try { |
114 | | - actualClient.get(urlBase).also { |
115 | | - if (apiVersion == null) { |
116 | | - // Tried EXTENDED and it worked; remember that for the future |
117 | | - apiVersion = ApiVersion.EXTENDED |
| 69 | + // if we have previously served IMDS credentials and it's not time for a refresh, just return the previous credentials |
| 70 | + mu.withLock { |
| 71 | + previousCredentials?.run { |
| 72 | + nextRefresh?.takeIf { clock.now() < it }?.run { |
| 73 | + return previousCredentials!! |
118 | 74 | } |
119 | 75 | } |
120 | | - } catch (ex: EC2MetadataError) { |
121 | | - when { |
122 | | - apiVersion == null && ex.status == HttpStatusCode.NotFound -> { |
123 | | - // Tried EXTENDED and that didn't work; fallback to LEGACY |
124 | | - apiVersion = ApiVersion.LEGACY |
125 | | - return resolveSingleFlight() |
126 | | - } |
127 | | - |
128 | | - ex.status == HttpStatusCode.NotFound -> { |
129 | | - coroutineContext.info<ImdsCredentialsProvider> { |
130 | | - "Received 404 when loading profile name. This instance may not have an associated profile." |
131 | | - } |
132 | | - throw ex |
133 | | - } |
134 | | - |
135 | | - else -> return usePreviousCredentials() |
136 | | - ?: throw ImdsProfileException(ex).wrapAsCredentialsProviderException() |
137 | | - } |
138 | | - } catch (ex: IOException) { |
139 | | - return usePreviousCredentials() ?: throw ImdsProfileException(ex).wrapAsCredentialsProviderException() |
140 | | - } catch (ex: Exception) { |
141 | | - throw ImdsProfileException(ex).wrapAsCredentialsProviderException() |
142 | 76 | } |
143 | 77 |
|
144 | | - val credsPayload = try { |
145 | | - actualClient.get("$urlBase$profileName") |
146 | | - } catch (ex: EC2MetadataError) { |
147 | | - when { |
148 | | - apiVersion == null && ex.status == HttpStatusCode.NotFound -> { |
149 | | - // Tried EXTENDED and that didn't work; fallback to LEGACY |
150 | | - apiVersion = ApiVersion.LEGACY |
151 | | - return resolveSingleFlight() |
152 | | - } |
153 | | - |
154 | | - instanceProfileName.get() == null && ex.status == HttpStatusCode.NotFound -> { |
155 | | - // A previously-resolved profile is now invalid; forget the resolved name and re-resolve |
156 | | - resolvedProfileName = null |
157 | | - return resolveSingleFlight() |
158 | | - } |
159 | | - |
160 | | - else -> return usePreviousCredentials() |
161 | | - ?: throw ImdsCredentialsException(profileName, ex).wrapAsCredentialsProviderException() |
162 | | - } |
163 | | - } catch (ex: IOException) { |
164 | | - return usePreviousCredentials() |
165 | | - ?: throw ImdsCredentialsException(profileName, ex).wrapAsCredentialsProviderException() |
| 78 | + val profileName = try { |
| 79 | + profileOverride ?: loadProfile() |
166 | 80 | } catch (ex: Exception) { |
167 | | - throw ImdsCredentialsException(profileName, ex).wrapAsCredentialsProviderException() |
| 81 | + return useCachedCredentials(ex) ?: throw CredentialsProviderException("failed to load instance profile", ex) |
168 | 82 | } |
169 | 83 |
|
170 | | - if (instanceProfileName.get() == null) { |
171 | | - // No profile name was provided at construction time; cache the resolved name |
172 | | - resolvedProfileName = profileName |
| 84 | + val payload = try { |
| 85 | + client.value.get("$CREDENTIALS_BASE_PATH$profileName") |
| 86 | + } catch (ex: Exception) { |
| 87 | + return useCachedCredentials(ex) ?: throw CredentialsProviderException("failed to load credentials", ex) |
173 | 88 | } |
174 | 89 |
|
175 | | - val deserializer = JsonDeserializer(credsPayload.encodeToByteArray()) |
| 90 | + val deserializer = JsonDeserializer(payload.encodeToByteArray()) |
176 | 91 |
|
177 | 92 | return when (val resp = deserializeJsonCredentials(deserializer)) { |
178 | 93 | is JsonCredentialsResponse.SessionCredentials -> { |
179 | | - val creds = credentials( |
| 94 | + nextRefresh = if (resp.expiration != null && resp.expiration < clock.now()) { |
| 95 | + coroutineContext.warn<ImdsCredentialsProvider> { |
| 96 | + "Attempting credential expiration extension due to a credential service availability issue. " + |
| 97 | + "A refresh of these credentials will be attempted again in " + |
| 98 | + "${ DEFAULT_CREDENTIALS_REFRESH_SECONDS / 60 } minutes." |
| 99 | + } |
| 100 | + clock.now() + DEFAULT_CREDENTIALS_REFRESH_SECONDS.seconds |
| 101 | + } else { |
| 102 | + null |
| 103 | + } |
| 104 | + |
| 105 | + val creds = Credentials( |
180 | 106 | resp.accessKeyId, |
181 | 107 | resp.secretAccessKey, |
182 | 108 | resp.sessionToken, |
183 | 109 | resp.expiration, |
184 | 110 | PROVIDER_NAME, |
185 | | - resp.accountId, |
186 | 111 | ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_IMDS) |
187 | 112 |
|
188 | | - creds.also { previousCredentials = it } |
| 113 | + creds.also { |
| 114 | + mu.withLock { previousCredentials = it } |
| 115 | + } |
189 | 116 | } |
190 | | - is JsonCredentialsResponse.Error -> when (resp.code) { |
191 | | - CODE_ASSUME_ROLE_UNAUTHORIZED_ACCESS -> throw ProviderConfigurationException("Incorrect IMDS/IAM configuration: [${resp.code}] ${resp.message}. Hint: Does this role have a trust relationship with EC2?") |
192 | | - else -> throw CredentialsProviderException("Error retrieving credentials from IMDS: code=${resp.code}; ${resp.message}") |
| 117 | + is JsonCredentialsResponse.Error -> { |
| 118 | + when (resp.code) { |
| 119 | + CODE_ASSUME_ROLE_UNAUTHORIZED_ACCESS -> throw ProviderConfigurationException("Incorrect IMDS/IAM configuration: [${resp.code}] ${resp.message}. Hint: Does this role have a trust relationship with EC2?") |
| 120 | + else -> throw CredentialsProviderException("Error retrieving credentials from IMDS: code=${resp.code}; ${resp.message}") |
| 121 | + } |
193 | 122 | } |
194 | 123 | } |
195 | 124 | } |
196 | 125 |
|
197 | 126 | override fun close() { |
198 | | - if (manageClient) { |
199 | | - actualClient.close() |
| 127 | + if (client.isInitialized()) { |
| 128 | + client.value.close() |
200 | 129 | } |
201 | 130 | } |
202 | 131 |
|
203 | | - private suspend fun usePreviousCredentials(): Credentials? = |
204 | | - previousCredentials?.apply { |
| 132 | + private suspend fun loadProfile() = try { |
| 133 | + client.value.get(CREDENTIALS_BASE_PATH) |
| 134 | + } catch (ex: EC2MetadataError) { |
| 135 | + if (ex.statusCode == HttpStatusCode.NotFound.value) { |
205 | 136 | coroutineContext.info<ImdsCredentialsProvider> { |
206 | | - "Attempting to reuse previously-fetched credentials (expiration = $expiration)" |
| 137 | + "Received 404 from IMDS when loading profile information. Hint: This instance may not have an " + |
| 138 | + "IAM role associated." |
207 | 139 | } |
208 | 140 | } |
209 | | - |
210 | | - override fun toString(): String = this.simpleClassName |
211 | | - |
212 | | - /** |
213 | | - * Identifies different versions of IMDS APIs for fetching credentials |
214 | | - */ |
215 | | - private enum class ApiVersion(val urlBase: String) { |
216 | | - /** |
217 | | - * The original, now-deprecated API |
218 | | - */ |
219 | | - LEGACY("/latest/meta-data/iam/security-credentials/"), |
220 | | - |
221 | | - /** |
222 | | - * The new API which provides `AccountId` and potentially other fields in the future |
223 | | - */ |
224 | | - EXTENDED("/latest/meta-data/iam/security-credentials-extended/"), |
| 141 | + throw ex |
225 | 142 | } |
226 | | -} |
227 | | - |
228 | | -internal class ImdsCredentialsException( |
229 | | - profileName: String, |
230 | | - cause: Throwable, |
231 | | -) : RuntimeException("Failed to load credentials for EC2 instance profile \"$profileName\"", cause) |
232 | 143 |
|
233 | | -internal class ImdsProfileException(cause: Throwable) : RuntimeException("Failed to load instance profile name", cause) |
| 144 | + private suspend fun useCachedCredentials(ex: Exception): Credentials? = when { |
| 145 | + ex is IOException || ex is EC2MetadataError && ex.statusCode == HttpStatusCode.InternalServerError.value -> { |
| 146 | + mu.withLock { |
| 147 | + previousCredentials?.apply { nextRefresh = clock.now() + DEFAULT_CREDENTIALS_REFRESH_SECONDS.seconds } |
| 148 | + } |
| 149 | + } |
| 150 | + else -> null |
| 151 | + } |
234 | 152 |
|
235 | | -private fun Throwable.wrapAsCredentialsProviderException() = |
236 | | - CredentialsProviderException(message.orEmpty(), this) |
| 153 | + override fun toString(): String = this.simpleClassName |
| 154 | +} |
0 commit comments