class Mongo::Auth::Aws::Request

Helper class for working with AWS requests.

The primary purpose of this class is to produce the canonical AWS STS request and calculate the signed headers and signature for it.

@api private

Constants

STS_REQUEST_BODY

The body of the STS GetCallerIdentity request.

This is currently the only request that this class supports making.

VALIDATE_TIMEOUT

The timeout, in seconds, to use for validating credentials via STS.

Attributes

access_key_id[R]

@return [ String ] access_key_id The access key id.

host[R]

@return [ String ] host The value of Host HTTP header to use.

secret_access_key[R]

@return [ String ] secret_access_key The secret access key.

server_nonce[R]

@return [ String ] server_nonce The server nonce binary string.

session_token[R]

@return [ String ] session_token The session token for temporary

credentials.
time[R]

@return [ Time ] time The time of the request.

Public Class Methods

new(access_key_id:, secret_access_key:, session_token: nil, host:, server_nonce:, time: Time.now ) click to toggle source

Constructs the request.

@note By overriding the time, it is possible to create reproducible

requests (in other words, replay a request).

@param [ String ] access_key_id The access key id. @param [ String ] secret_access_key The secret access key. @param [ String ] session_token The session token for temporary

credentials.

@param [ String ] host The value of Host HTTP header to use. @param [ String ] server_nonce The server nonce binary string. @param [ Time ] time The time of the request.

# File lib/mongo/auth/aws/request.rb, line 54
def initialize(access_key_id:, secret_access_key:, session_token: nil,
  host:, server_nonce:, time: Time.now
)
  @access_key_id = access_key_id
  @secret_access_key = secret_access_key
  @session_token = session_token
  @host = host
  @server_nonce = server_nonce
  @time = time

  %i(access_key_id secret_access_key host server_nonce).each do |arg|
    value = instance_variable_get("@#{arg}")
    if value.nil? || value.empty?
      raise Error::InvalidServerAuthResponse, "Value for '#{arg}' is required"
    end
  end

  if host && host.length > 255
      raise Error::InvalidServerAuthHost, "Value for 'host' is too long: #{@host}"
  end
end

Public Instance Methods

authorization() click to toggle source

Returns the value of the Authorization header, per the AWS signature V4 specification.

@return [ String ] Authorization header value.

# File lib/mongo/auth/aws/request.rb, line 236
def authorization
  "AWS4-HMAC-SHA256 Credential=#{access_key_id}/#{scope}, SignedHeaders=#{signed_headers_string}, Signature=#{signature}"
end
canonical_request() click to toggle source

Returns the canonical request used during calculation of AWS V4 signature.

@return [ String ] The canonical request.

# File lib/mongo/auth/aws/request.rb, line 196
def canonical_request
  headers = headers_to_sign
  serialized_headers = headers.map do |k, v|
    "#{k}:#{v}"
  end.join("\n")
  hashed_payload = Digest::SHA256.new.update(STS_REQUEST_BODY).hexdigest
  "POST\n/\n\n" +
    # There are two newlines after serialized headers because the
    # signature V4 specification treats each header as containing the
    # terminating newline, and there is an additional newline
    # separating headers from the signed header names.
    "#{serialized_headers}\n\n" +
    "#{signed_headers_string}\n" +
    hashed_payload
end
formatted_date() click to toggle source

@return [ String ] formatted_date YYYYMMDD formatted date of the request.

# File lib/mongo/auth/aws/request.rb, line 102
def formatted_date
  formatted_time[0, 8]
end
formatted_time() click to toggle source

@return [ String ] formatted_time ISO8601-formatted time of the

request, as would be used in X-Amz-Date header.
# File lib/mongo/auth/aws/request.rb, line 97
def formatted_time
  @formatted_time ||= @time.getutc.strftime('%Y%m%dT%H%M%SZ')
end
headers() click to toggle source

Returns the hash containing the headers of the calculated canonical request.

@note Not all of these headers are part of the signed headers list,

the keys of the hash are not necessarily ordered lexicographically,
and the keys may be in any case.

@return [ <Hash> ] headers The headers.

# File lib/mongo/auth/aws/request.rb, line 147
def headers
  headers = {
    'content-length' => STS_REQUEST_BODY.length.to_s,
    'content-type' => 'application/x-www-form-urlencoded',
    'host' => host,
    'x-amz-date' => formatted_time,
    'x-mongodb-gs2-cb-flag' => 'n',
    'x-mongodb-server-nonce' => Base64.encode64(server_nonce).gsub("\n", ''),
  }
  if session_token
    headers['x-amz-security-token'] = session_token
  end
  headers
end
headers_to_sign() click to toggle source

Returns the hash containing the headers of the calculated canonical request that should be signed, in a ready to sign form.

The differences between headers and this method is this method:

  • Removes any headers that are not to be signed. Per AWS specifications it should be possible to sign all headers, but MongoDB server expects only some headers to be signed and will not form the correct request if other headers are signed.

  • Lowercases all header names.

  • Orders the headers lexicographically in the hash.

@return [ <Hash> ] headers The headers.

# File lib/mongo/auth/aws/request.rb, line 175
def headers_to_sign
  headers_to_sign = {}
  headers.keys.sort_by { |k| k.downcase }.each do |key|
    write_key = key.downcase
    headers_to_sign[write_key] = headers[key]
  end
  headers_to_sign
end
region() click to toggle source

@return [ String ] region The region of the host, derived from the host.

# File lib/mongo/auth/aws/request.rb, line 107
def region
  # Common case
  if host == 'sts.amazonaws.com'
    return 'us-east-1'
  end

  if host.start_with?('.')
    raise Error::InvalidServerAuthHost, "Host begins with a period: #{host}"
  end
  if host.end_with?('.')
    raise Error::InvalidServerAuthHost, "Host ends with a period: #{host}"
  end

  parts = host.split('.')
  if parts.any? { |part| part.empty? }
    raise Error::InvalidServerAuthHost, "Host has an empty component: #{host}"
  end

  if parts.length == 1
    'us-east-1'
  else
    parts[1]
  end
end
scope() click to toggle source

Returns the scope of the request, per the AWS signature V4 specification.

@return [ String ] The scope.

# File lib/mongo/auth/aws/request.rb, line 135
def scope
  "#{formatted_date}/#{region}/sts/aws4_request"
end
signature() click to toggle source

Returns the calculated signature of the canonical request, per the AWS signature V4 specification.

@return [ String ] The signature.

# File lib/mongo/auth/aws/request.rb, line 216
def signature
  hashed_canonical_request = Digest::SHA256.hexdigest(canonical_request)
  hashed_body = Digest::SHA256.new.update(STS_REQUEST_BODY).hexdigest
  string_to_sign = "AWS4-HMAC-SHA256\n" +
    "#{formatted_time}\n" +
    "#{scope}\n" +
    hashed_canonical_request
  # All of the intermediate HMAC operations are not hex-encoded.
  mac = hmac("AWS4#{secret_access_key}", formatted_date)
  mac = hmac(mac, region)
  mac = hmac(mac, 'sts')
  signing_key = hmac(mac, 'aws4_request')
  # Only the final HMAC operation is hex-encoded.
  hmac_hex(signing_key, string_to_sign)
end
signed_headers_string() click to toggle source

Returns semicolon-separated list of names of signed headers, per the AWS signature V4 specification.

@return [ String ] The signed header list.

# File lib/mongo/auth/aws/request.rb, line 188
def signed_headers_string
  headers_to_sign.keys.join(';')
end
validate!() click to toggle source

Validates the credentials and the constructed request components by sending a real STS GetCallerIdentity request.

@return [ Hash ] GetCallerIdentity result.

# File lib/mongo/auth/aws/request.rb, line 244
def validate!
  sts_request = Net::HTTP::Post.new("https://#{host}").tap do |req|
    headers.each do |k, v|
      req[k] = v
    end
    req['authorization'] = authorization
    req['accept'] = 'application/json'
    req.body = STS_REQUEST_BODY
  end
  http = Net::HTTP.new(host, 443)
  http.use_ssl = true
  http.start do
    resp = Timeout.timeout(VALIDATE_TIMEOUT, Error::CredentialCheckError, 'GetCallerIdentity request timed out') do
      http.request(sts_request)
    end
    payload = JSON.parse(resp.body)
    if resp.code != '200'
      aws_code = payload.fetch('Error').fetch('Code')
      aws_message = payload.fetch('Error').fetch('Message')
      msg = "Credential check for user #{access_key_id} failed with HTTP status code #{resp.code}: #{aws_code}: #{aws_message}"
      msg += '.' unless msg.end_with?('.')
      msg += " Please check that the credentials are valid, and if they are temporary (i.e. use the session token) that the session token is provided and not expired"
      raise Error::CredentialCheckError, msg
    end
    payload.fetch('GetCallerIdentityResponse').fetch('GetCallerIdentityResult')
  end
end

Private Instance Methods

hmac(key, data) click to toggle source
# File lib/mongo/auth/aws/request.rb, line 274
def hmac(key, data)
  OpenSSL::HMAC.digest("SHA256", key, data)
end
hmac_hex(key, data) click to toggle source
# File lib/mongo/auth/aws/request.rb, line 278
def hmac_hex(key, data)
  OpenSSL::HMAC.hexdigest("SHA256", key, data)
end