1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410
|
# frozen_string_literal: true
# rubocop:todo all
# Copyright (C) 2020 MongoDB Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
module Mongo
module Auth
class Aws
# Raised when trying to authorize with an invalid configuration
#
# @api private
class CredentialsNotFound < Mongo::Error::AuthError
def initialize
super("Could not locate AWS credentials (checked Client URI and Ruby options, environment variables, ECS and EC2 metadata, and Web Identity)")
end
end
# Retrieves AWS credentials from a variety of sources.
#
# This class provides for AWS credentials retrieval from:
# - the passed user (which receives the credentials passed to the
# client via URI options and Ruby options)
# - AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN
# environment variables (commonly used by AWS SDKs and various tools,
# as well as AWS Lambda)
# - AssumeRoleWithWebIdentity API call
# - EC2 metadata endpoint
# - ECS metadata endpoint
#
# The sources listed above are consulted in the order specified.
# The first source that contains any of the three credential components
# (access key id, secret access key or session token) is used.
# The credential components must form a valid set if any of the components
# is specified; meaning, access key id and secret access key must
# always be provided together, and if a session token is provided
# the key id and secret key must also be provided. If a source provides
# partial credentials, credential retrieval fails with an exception.
#
# @api private
class CredentialsRetriever
# Timeout for metadata operations, in seconds.
#
# The auth spec suggests a 10 second timeout but this seems
# excessively long given that the endpoint is essentially local.
METADATA_TIMEOUT = 5
# @param [ Auth::User | nil ] user The user object, if one was provided.
# @param [ Auth::Aws::CredentialsCache ] credentials_cache The credentials cache.
def initialize(user = nil, credentials_cache: CredentialsCache.instance)
@user = user
@credentials_cache = credentials_cache
end
# @return [ Auth::User | nil ] The user object, if one was provided.
attr_reader :user
# Retrieves a valid set of credentials, if possible, or raises
# Auth::InvalidConfiguration.
#
# @param [ CsotTimeoutHolder | nil ] timeout_holder CSOT timeout, if any.
#
# @return [ Auth::Aws::Credentials ] A valid set of credentials.
#
# @raise Auth::InvalidConfiguration if a source contains an invalid set
# of credentials.
# @raise Auth::Aws::CredentialsNotFound if credentials could not be
# retrieved from any source.
# @raise Error::TimeoutError if credentials cannot be retrieved within
# the timeout defined on the operation context.
def credentials(timeout_holder = nil)
credentials = credentials_from_user(user)
return credentials unless credentials.nil?
credentials = credentials_from_environment
return credentials unless credentials.nil?
credentials = @credentials_cache.fetch { obtain_credentials_from_endpoints(timeout_holder) }
return credentials unless credentials.nil?
raise Auth::Aws::CredentialsNotFound
end
private
# Returns credentials from the user object.
#
# @param [ Auth::User | nil ] user The user object, if one was provided.
#
# @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
#
# @raise Auth::InvalidConfiguration if a source contains an invalid set
# of credentials.
def credentials_from_user(user)
return nil unless user
credentials = Credentials.new(
user.name,
user.password,
user.auth_mech_properties['aws_session_token']
)
return credentials if credentials_valid?(credentials, 'Mongo::Client URI or Ruby options')
end
# Returns credentials from environment variables.
#
# @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
# if retrieval failed or the obtained credentials are invalid.
#
# @raise Auth::InvalidConfiguration if a source contains an invalid set
# of credentials.
def credentials_from_environment
credentials = Credentials.new(
ENV['AWS_ACCESS_KEY_ID'],
ENV['AWS_SECRET_ACCESS_KEY'],
ENV['AWS_SESSION_TOKEN']
)
credentials if credentials && credentials_valid?(credentials, 'environment variables')
end
# Returns credentials from the AWS metadata endpoints.
#
# @param [ CsotTimeoutHolder ] timeout_holder CSOT timeout.
#
# @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
# if retrieval failed or the obtained credentials are invalid.
#
# @raise Auth::InvalidConfiguration if a source contains an invalid set
# of credentials.
# @ raise Error::TimeoutError if credentials cannot be retrieved within
# the timeout defined on the operation context.
def obtain_credentials_from_endpoints(timeout_holder = nil)
if (credentials = web_identity_credentials(timeout_holder)) && credentials_valid?(credentials, 'Web identity token')
credentials
elsif (credentials = ecs_metadata_credentials(timeout_holder)) && credentials_valid?(credentials, 'ECS task metadata')
credentials
elsif (credentials = ec2_metadata_credentials(timeout_holder)) && credentials_valid?(credentials, 'EC2 instance metadata')
credentials
end
end
# Returns credentials from the EC2 metadata endpoint. The credentials
# could be empty, partial or invalid.
#
# @param [ CsotTimeoutHolder ] timeout_holder CSOT timeout.
#
# @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
# if retrieval failed.
# @ raise Error::TimeoutError if credentials cannot be retrieved within
# the timeout.
def ec2_metadata_credentials(timeout_holder = nil)
timeout_holder&.check_timeout!
http = Net::HTTP.new('169.254.169.254')
req = Net::HTTP::Put.new('/latest/api/token',
# The TTL is required in order to obtain the metadata token.
{'x-aws-ec2-metadata-token-ttl-seconds' => '30'})
resp = with_timeout(timeout_holder) do
http.request(req)
end
if resp.code != '200'
return nil
end
metadata_token = resp.body
resp = with_timeout(timeout_holder) do
http_get(http, '/latest/meta-data/iam/security-credentials', metadata_token)
end
if resp.code != '200'
return nil
end
role_name = resp.body
escaped_role_name = CGI.escape(role_name).gsub('+', '%20')
resp = with_timeout(timeout_holder) do
http_get(http, "/latest/meta-data/iam/security-credentials/#{escaped_role_name}", metadata_token)
end
if resp.code != '200'
return nil
end
payload = JSON.parse(resp.body)
unless payload['Code'] == 'Success'
return nil
end
Credentials.new(
payload['AccessKeyId'],
payload['SecretAccessKey'],
payload['Token'],
DateTime.parse(payload['Expiration']).to_time
)
# When trying to use the EC2 metadata endpoint on ECS:
# Errno::EINVAL: Failed to open TCP connection to 169.254.169.254:80 (Invalid argument - connect(2) for "169.254.169.254" port 80)
rescue ::Timeout::Error, IOError, SystemCallError, TypeError
return nil
end
# Returns credentials from the ECS metadata endpoint. The credentials
# could be empty, partial or invalid.
#
# @param [ CsotTimeoutHolder | nil ] timeout_holder CSOT timeout.
#
# @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
# if retrieval failed.
# @ raise Error::TimeoutError if credentials cannot be retrieved within
# the timeout defined on the operation context.
def ecs_metadata_credentials(timeout_holder = nil)
timeout_holder&.check_timeout!
relative_uri = ENV['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI']
if relative_uri.nil? || relative_uri.empty?
return nil
end
http = Net::HTTP.new('169.254.170.2')
# Per https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html
# the value in AWS_CONTAINER_CREDENTIALS_RELATIVE_URI includes
# the leading slash.
# The current language in MONGODB-AWS specification implies that
# a leading slash must be added by the driver, but this is not
# in fact needed.
req = Net::HTTP::Get.new(relative_uri)
resp = with_timeout(timeout_holder) do
http.request(req)
end
if resp.code != '200'
return nil
end
payload = JSON.parse(resp.body)
Credentials.new(
payload['AccessKeyId'],
payload['SecretAccessKey'],
payload['Token'],
DateTime.parse(payload['Expiration']).to_time
)
rescue ::Timeout::Error, IOError, SystemCallError, TypeError
return nil
end
# Returns credentials associated with web identity token that is
# stored in a file. This authentication mechanism is used to authenticate
# inside EKS. See https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html
# for further details.
#
# @param [ CsotTimeoutHolder | nil ] timeout_holder CSOT timeout.
#
# @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
# if retrieval failed.
def web_identity_credentials(timeout_holder = nil)
web_identity_token, role_arn, role_session_name = prepare_web_identity_inputs
return nil if web_identity_token.nil?
response = request_web_identity_credentials(
web_identity_token, role_arn, role_session_name, timeout_holder
)
return if response.nil?
credentials_from_web_identity_response(response)
end
# Returns inputs for the AssumeRoleWithWebIdentity AWS API call.
#
# @return [ Array<String | nil, String | nil, String | nil> ] Web
# identity token, role arn, and role session name.
def prepare_web_identity_inputs
token_file = ENV['AWS_WEB_IDENTITY_TOKEN_FILE']
role_arn = ENV['AWS_ROLE_ARN']
if token_file.nil? || role_arn.nil?
return nil
end
web_identity_token = File.open(token_file).read
role_session_name = ENV['AWS_ROLE_SESSION_NAME']
if role_session_name.nil?
role_session_name = "ruby-app-#{SecureRandom.alphanumeric(50)}"
end
[web_identity_token, role_arn, role_session_name]
rescue Errno::ENOENT, IOError, SystemCallError
nil
end
# Calls AssumeRoleWithWebIdentity to obtain credentials for the
# given web identity token.
#
# @param [ String ] token The OAuth 2.0 access token or
# OpenID Connect ID token that is provided by the identity provider.
# @param [ String ] role_arn The Amazon Resource Name (ARN) of the role
# that the caller is assuming.
# @param [ String ] role_session_name An identifier for the assumed
# role session.
# @param [ CsotTimeoutHolder | nil ] timeout_holder CSOT timeout.
#
# @return [ Net::HTTPResponse | nil ] AWS API response if successful,
# otherwise nil.
#
# @ raise Error::TimeoutError if credentials cannot be retrieved within
# the timeout defined on the operation context.
def request_web_identity_credentials(token, role_arn, role_session_name, timeout_holder)
timeout_holder&.check_timeout!
uri = URI('https://sts.amazonaws.com/')
params = {
'Action' => 'AssumeRoleWithWebIdentity',
'Version' => '2011-06-15',
'RoleArn' => role_arn,
'WebIdentityToken' => token,
'RoleSessionName' => role_session_name
}
uri.query = ::URI.encode_www_form(params)
req = Net::HTTP::Post.new(uri)
req['Accept'] = 'application/json'
resp = with_timeout(timeout_holder) do
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |https|
https.request(req)
end
end
if resp.code != '200'
return nil
end
resp
rescue Errno::ENOENT, IOError, SystemCallError
nil
end
# Extracts credentials from AssumeRoleWithWebIdentity response.
#
# @param [ Net::HTTPResponse ] response AssumeRoleWithWebIdentity
# call response.
#
# @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
# if response parsing failed.
def credentials_from_web_identity_response(response)
payload = JSON.parse(response.body).dig(
'AssumeRoleWithWebIdentityResponse',
'AssumeRoleWithWebIdentityResult',
'Credentials'
) || {}
Credentials.new(
payload['AccessKeyId'],
payload['SecretAccessKey'],
payload['SessionToken'],
Time.at(payload['Expiration'])
)
rescue JSON::ParserError, TypeError
nil
end
def http_get(http, uri, metadata_token)
req = Net::HTTP::Get.new(uri,
{'x-aws-ec2-metadata-token' => metadata_token})
http.request(req)
end
# Checks whether the credentials provided are valid.
#
# Returns true if they are valid, false if they are empty, and
# raises Auth::InvalidConfiguration if the credentials are
# incomplete (i.e. some of the components are missing).
def credentials_valid?(credentials, source)
unless credentials.access_key_id || credentials.secret_access_key ||
credentials.session_token
then
return false
end
if credentials.access_key_id || credentials.secret_access_key
if credentials.access_key_id && !credentials.secret_access_key
raise Auth::InvalidConfiguration,
"Access key ID is provided without secret access key (source: #{source})"
end
if credentials.secret_access_key && !credentials.access_key_id
raise Auth::InvalidConfiguration,
"Secret access key is provided without access key ID (source: #{source})"
end
elsif credentials.session_token
raise Auth::InvalidConfiguration,
"Session token is provided without access key ID or secret access key (source: #{source})"
end
true
end
# Execute the given block considering the timeout defined on the context,
# or the default timeout value.
#
# We use +Timeout.timeout+ here because there is no other acceptable easy
# way to time limit http requests.
#
# @param [ CsotTimeoutHolder | nil ] timeout_holder CSOT timeout.
#
# @ raise Error::TimeoutError if deadline exceeded.
def with_timeout(timeout_holder)
timeout = timeout_holder&.remaining_timeout_sec! || METADATA_TIMEOUT
exception_class = if timeout_holder&.csot?
Error::TimeoutError
else
nil
end
::Timeout.timeout(timeout, exception_class) do
yield
end
end
end
end
end
end
|