Skip to content

Commit 03dbe22

Browse files
RUBY-3066 Cache AWS Credentials Where Possible (#2695)
1 parent b92b7f4 commit 03dbe22

10 files changed

+438
-47
lines changed

lib/mongo/auth/aws.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,16 @@ class Aws < Base
2525
# @return [ BSON::Document ] The document of the authentication response.
2626
def login
2727
converse_2_step(connection, conversation)
28+
rescue StandardError
29+
CredentialsCache.instance.clear
30+
raise
2831
end
29-
30-
# The AWS credential set.
31-
#
32-
# @api private
33-
Credentials = Struct.new(:access_key_id, :secret_access_key, :session_token)
3432
end
3533
end
3634
end
3735

3836
require 'mongo/auth/aws/conversation'
37+
require 'mongo/auth/aws/credentials'
38+
require 'mongo/auth/aws/credentials_cache'
3939
require 'mongo/auth/aws/credentials_retriever'
4040
require 'mongo/auth/aws/request'

lib/mongo/auth/aws/credentials.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright (C) 2023-present MongoDB Inc.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
module Mongo
18+
module Auth
19+
class Aws
20+
# The AWS credential set.
21+
#
22+
# @api private
23+
Credentials = Struct.new(:access_key_id, :secret_access_key, :session_token, :expiration) do
24+
# @return [ true | false ] Whether the credentials have expired.
25+
def expired?
26+
if expiration.nil?
27+
false
28+
else
29+
# According to the spec, Credentials are considered
30+
# valid if they are more than five minutes away from expiring.
31+
Time.now.utc >= expiration - 300
32+
end
33+
end
34+
end
35+
end
36+
end
37+
end
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright (C) 2023-present MongoDB Inc.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
module Mongo
18+
module Auth
19+
class Aws
20+
# Thread safe cache to store AWS credentials.
21+
#
22+
# @api private
23+
class CredentialsCache
24+
# Get or create the singleton instance of the cache.
25+
#
26+
# @return [ CredentialsCache ] The singleton instance.
27+
def self.instance
28+
@instance ||= new
29+
end
30+
31+
def initialize
32+
@lock = Mutex.new
33+
@credentials = nil
34+
end
35+
36+
# Set the credentials in the cache.
37+
#
38+
# @param [ Aws::Credentials ] credentials The credentials to cache.
39+
def credentials=(credentials)
40+
@lock.synchronize do
41+
@credentials = credentials
42+
end
43+
end
44+
45+
# Get the credentials from the cache.
46+
#
47+
# @return [ Aws::Credentials ] The cached credentials.
48+
def credentials
49+
@lock.synchronize do
50+
@credentials
51+
end
52+
end
53+
54+
# Fetch the credentials from the cache or yield to get them
55+
# if they are not in the cache or have expired.
56+
#
57+
# @return [ Aws::Credentials ] The cached credentials.
58+
def fetch
59+
@lock.synchronize do
60+
@credentials = yield if @credentials.nil? || @credentials.expired?
61+
@credentials
62+
end
63+
end
64+
65+
# Clear the credentials from the cache.
66+
def clear
67+
@lock.synchronize do
68+
@credentials = nil
69+
end
70+
end
71+
end
72+
end
73+
end
74+
end

lib/mongo/auth/aws/credentials_retriever.rb

Lines changed: 68 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
module Mongo
1919
module Auth
2020
class Aws
21-
2221
# Raised when trying to authorize with an invalid configuration
2322
#
2423
# @api private
@@ -51,15 +50,17 @@ def initialize
5150
#
5251
# @api private
5352
class CredentialsRetriever
54-
5553
# Timeout for metadata operations, in seconds.
5654
#
5755
# The auth spec suggests a 10 second timeout but this seems
5856
# excessively long given that the endpoint is essentially local.
5957
METADATA_TIMEOUT = 5
6058

61-
def initialize(user = nil)
59+
# @param [ Auth::User | nil ] user The user object, if one was provided.
60+
# @param [ Auth::Aws::CredentialsCache ] credentials_cache The credentials cache.
61+
def initialize(user = nil, credentials_cache: CredentialsCache.instance)
6262
@user = user
63+
@credentials_cache = credentials_cache
6364
end
6465

6566
# @return [ Auth::User | nil ] The user object, if one was provided.
@@ -70,55 +71,77 @@ def initialize(user = nil)
7071
#
7172
# @return [ Auth::Aws::Credentials ] A valid set of credentials.
7273
#
73-
# @raise Auth::InvalidConfiguration if credentials could not be
74-
# retrieved for any reason, or if a source contains an invalid set
74+
# @raise Auth::InvalidConfiguration if a source contains an invalid set
7575
# of credentials.
76+
# @raise Auth::Aws::CredentialsNotFound if credentials could not be
77+
# retrieved from any source.
7678
def credentials
77-
if user
78-
credentials = Credentials.new(
79-
user.name,
80-
user.password,
81-
user.auth_mech_properties['aws_session_token'],
82-
)
83-
84-
if credentials_valid?(credentials, 'Mongo::Client URI or Ruby options')
85-
return credentials
86-
end
87-
end
79+
credentials = credentials_from_user(user)
80+
return credentials unless credentials.nil?
8881

89-
credentials = Credentials.new(
90-
ENV['AWS_ACCESS_KEY_ID'],
91-
ENV['AWS_SECRET_ACCESS_KEY'],
92-
ENV['AWS_SESSION_TOKEN'],
93-
)
82+
credentials = credentials_from_environment
83+
return credentials unless credentials.nil?
9484

95-
if credentials_valid?(credentials, 'environment variables')
96-
return credentials
97-
end
85+
credentials = @credentials_cache.fetch { obtain_credentials_from_endpoints }
86+
return credentials unless credentials.nil?
9887

99-
credentials = web_identity_credentials
88+
raise Auth::Aws::CredentialsNotFound
89+
end
10090

101-
if credentials && credentials_valid?(credentials, 'Web identity token')
102-
return credentials
103-
end
91+
private
10492

105-
credentials = ecs_metadata_credentials
93+
# Returns credentials from the user object.
94+
#
95+
# @param [ Auth::User | nil ] user The user object, if one was provided.
96+
#
97+
# @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
98+
#
99+
# @raise Auth::InvalidConfiguration if a source contains an invalid set
100+
# of credentials.
101+
def credentials_from_user(user)
102+
return nil unless user
106103

107-
if credentials && credentials_valid?(credentials, 'ECS task metadata')
108-
return credentials
109-
end
104+
credentials = Credentials.new(
105+
user.name,
106+
user.password,
107+
user.auth_mech_properties['aws_session_token']
108+
)
109+
return credentials if credentials_valid?(credentials, 'Mongo::Client URI or Ruby options')
110+
end
110111

111-
credentials = ec2_metadata_credentials
112+
# Returns credentials from environment variables.
113+
#
114+
# @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
115+
# if retrieval failed or the obtained credentials are invalid.
116+
#
117+
# @raise Auth::InvalidConfiguration if a source contains an invalid set
118+
# of credentials.
119+
def credentials_from_environment
120+
credentials = Credentials.new(
121+
ENV['AWS_ACCESS_KEY_ID'],
122+
ENV['AWS_SECRET_ACCESS_KEY'],
123+
ENV['AWS_SESSION_TOKEN']
124+
)
125+
credentials if credentials && credentials_valid?(credentials, 'environment variables')
126+
end
112127

113-
if credentials && credentials_valid?(credentials, 'EC2 instance metadata')
114-
return credentials
128+
# Returns credentials from the AWS metadata endpoints.
129+
#
130+
# @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
131+
# if retrieval failed or the obtained credentials are invalid.
132+
#
133+
# @raise Auth::InvalidConfiguration if a source contains an invalid set
134+
# of credentials.
135+
def obtain_credentials_from_endpoints
136+
if (credentials = web_identity_credentials) && credentials_valid?(credentials, 'Web identity token')
137+
credentials
138+
elsif (credentials = ecs_metadata_credentials) && credentials_valid?(credentials, 'ECS task metadata')
139+
credentials
140+
elsif (credentials = ec2_metadata_credentials) && credentials_valid?(credentials, 'EC2 instance metadata')
141+
credentials
115142
end
116-
117-
raise Auth::Aws::CredentialsNotFound
118143
end
119144

120-
private
121-
122145
# Returns credentials from the EC2 metadata endpoint. The credentials
123146
# could be empty, partial or invalid.
124147
#
@@ -158,10 +181,11 @@ def ec2_metadata_credentials
158181
payload['AccessKeyId'],
159182
payload['SecretAccessKey'],
160183
payload['Token'],
184+
DateTime.parse(payload['Expiration']).to_time
161185
)
162186
# When trying to use the EC2 metadata endpoint on ECS:
163187
# Errno::EINVAL: Failed to open TCP connection to 169.254.169.254:80 (Invalid argument - connect(2) for "169.254.169.254" port 80)
164-
rescue ::Timeout::Error, IOError, SystemCallError
188+
rescue ::Timeout::Error, IOError, SystemCallError, TypeError
165189
return nil
166190
end
167191

@@ -190,8 +214,9 @@ def ecs_metadata_credentials
190214
payload['AccessKeyId'],
191215
payload['SecretAccessKey'],
192216
payload['Token'],
217+
DateTime.parse(payload['Expiration']).to_time
193218
)
194-
rescue ::Timeout::Error, IOError, SystemCallError
219+
rescue ::Timeout::Error, IOError, SystemCallError, TypeError
195220
return nil
196221
end
197222

@@ -284,8 +309,9 @@ def credentials_from_web_identity_response(response)
284309
payload['AccessKeyId'],
285310
payload['SecretAccessKey'],
286311
payload['SessionToken'],
312+
Time.at(payload['Expiration'])
287313
)
288-
rescue JSON::ParserError
314+
rescue JSON::ParserError, TypeError
289315
nil
290316
end
291317

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
5+
describe Mongo::Auth::Aws::CredentialsCache do
6+
require_auth 'aws-ec2', 'aws-ecs', 'aws-web-identity'
7+
8+
def new_client
9+
ClientRegistry.instance.new_authorized_client.tap do |client|
10+
@clients << client
11+
end
12+
end
13+
14+
before do
15+
@clients = []
16+
described_class.instance.clear
17+
end
18+
19+
after do
20+
@clients.each(&:close)
21+
end
22+
23+
it 'caches the credentials' do
24+
client1 = new_client
25+
client1['test-collection'].find.to_a
26+
expect(described_class.instance.credentials).not_to be_nil
27+
28+
described_class.instance.credentials = Mongo::Auth::Aws::Credentials.new(
29+
described_class.instance.credentials.access_key_id,
30+
described_class.instance.credentials.secret_access_key,
31+
described_class.instance.credentials.session_token,
32+
Time.now + 60
33+
)
34+
client2 = new_client
35+
client2['test-collection'].find.to_a
36+
expect(described_class.instance.credentials).not_to be_expired
37+
38+
described_class.instance.credentials = Mongo::Auth::Aws::Credentials.new(
39+
'bad_access_key_id',
40+
described_class.instance.credentials.secret_access_key,
41+
described_class.instance.credentials.session_token,
42+
described_class.instance.credentials.expiration
43+
)
44+
client3 = new_client
45+
expect { client3['test-collection'].find.to_a }.to raise_error(Mongo::Auth::Unauthorized)
46+
expect(described_class.instance.credentials).to be_nil
47+
expect { client3['test-collection'].find.to_a }.not_to raise_error
48+
expect(described_class.instance.credentials).not_to be_nil
49+
end
50+
end

spec/integration/aws_credentials_retriever_spec.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
Mongo::Auth::User.new(auth_mech: :aws)
2121
end
2222

23+
before do
24+
Mongo::Auth::Aws::CredentialsCache.instance.clear
25+
end
26+
2327
shared_examples_for 'retrieves the credentials' do
2428
it 'retrieves' do
2529
credentials.should be_a(Mongo::Auth::Aws::Credentials)

0 commit comments

Comments
 (0)