Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refreshing credentials in SharedCredentials #3078

2 changes: 2 additions & 0 deletions gems/aws-sdk-core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
Unreleased Changes
------------------

* Feature - Added support for opt-in credential refreshing in `SharedCredentials`. This allows users to enable or disable automatic credential refreshing by specifying the `:enable_refresh` option.

3.201.3 (2024-07-23)
------------------

Expand Down
77 changes: 43 additions & 34 deletions gems/aws-sdk-core/lib/aws-sdk-core/shared_credentials.rb
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
# frozen_string_literal: true

require_relative 'ini_parser'
require_relative 'refreshing_credentials'

module Aws
class SharedCredentials

include CredentialProvider

# @api private
KEY_MAP = {
'aws_access_key_id' => 'access_key_id',
'aws_secret_access_key' => 'secret_access_key',
'aws_session_token' => 'session_token',
}
include RefreshingCredentials

# Constructs a new SharedCredentials object. This will load static
# (access_key_id, secret_access_key and session_token) AWS access
Expand All @@ -33,49 +27,64 @@ class SharedCredentials
#
def initialize(options = {})
shared_config = Aws.shared_config
@path = options[:path]
@path ||= shared_config.credentials_path
@profile_name = options[:profile_name]
@profile_name ||= ENV['AWS_PROFILE']
@profile_name ||= shared_config.profile_name
if @path && @path == shared_config.credentials_path
@credentials = shared_config.credentials(profile: @profile_name)
else
config = SharedConfig.new(
credentials_path: @path,
profile_name: @profile_name
)
@credentials = config.credentials(profile: @profile_name)
end
@enable_refresh = options[:enable_refresh] || false # Refresh disabled by default
zendesk-duncanboynton marked this conversation as resolved.
Show resolved Hide resolved
@refresh_interval = options[:refresh_interval] || 10 * 60 # Default refresh interval: 10 minutes
zendesk-duncanboynton marked this conversation as resolved.
Show resolved Hide resolved
@path = options[:path] || shared_config.credentials_path
@profile_name = options[:profile_name] || ENV['AWS_PROFILE'] || shared_config.profile_name
@next_refresh_time = nil # Internal variable to track refresh timing

# Initialize the mutex for thread-safety
super(options) # Call super to initialize RefreshingCredentials

@credentials = load_credentials

refresh if @enable_refresh
end

# @return [String]
# @return [String] The path to the credentials file
attr_reader :path

# @return [String]
# @return [String] The name of the profile being used
attr_reader :profile_name

# @return [Credentials]
attr_reader :credentials
# Override of near_expiration to decide when to refresh
def near_expiration?(expiration_length = 0)
return false unless @enable_refresh

# Consider "near expiration" if the internal next_refresh_time has passed
@next_refresh_time.nil? || @next_refresh_time <= Time.now + expiration_length
end

# @api private
def inspect
parts = [
self.class.name,
"profile_name=#{profile_name.inspect}",
"path=#{path.inspect}",
"path=#{path.inspect}"
]
"#<#{parts.join(' ')}>"
end

# @deprecated This method is no longer used.
# @return [Boolean] Returns `true` if a credential file
# exists and has appropriate read permissions at {#path}.
# @note This method does not indicate if the file found at {#path}
# will be parsable, only if it can be read.
def loadable?
zendesk-duncanboynton marked this conversation as resolved.
Show resolved Hide resolved
!path.nil? && File.exist?(path) && File.readable?(path)
def refresh
@credentials = load_credentials

# Set next refresh time if credentials are present
# This is for internal tracking, and won't affect the actual credentials' expiration
@next_refresh_time = @credentials ? (Time.now + @refresh_interval) : nil
end

private

def load_credentials
if @path && @path == Aws.shared_config.credentials_path
Aws.shared_config.credentials(profile: @profile_name)
else
config = SharedConfig.new(
credentials_path: @path,
profile_name: @profile_name
)
config.credentials(profile: @profile_name)
end
end
end
end
112 changes: 82 additions & 30 deletions gems/aws-sdk-core/spec/aws/shared_credentials_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,31 @@

module Aws
describe SharedCredentials do

before(:each) do
stub_const('ENV', {})
allow(Dir).to receive(:home).and_raise(ArgumentError)
end

let(:mock_credential_file) {
let(:mock_credential_file) do
File.expand_path(File.join(File.dirname(__FILE__),
'..', 'fixtures', 'credentials', 'mock_shared_credentials'))
}
'..', 'fixtures', 'credentials', 'mock_shared_credentials'))
end

let(:mock_config_file) {
let(:mock_config_file) do
File.expand_path(File.join(File.dirname(__FILE__),
'..', 'fixtures', 'credentials', 'mock_shared_config'))
}
'..', 'fixtures', 'credentials', 'mock_shared_config'))
end

it 'reads the correct default credentials from a credentials file' do
creds = SharedCredentials.new(path:mock_credential_file).credentials
creds = SharedCredentials.new(path: mock_credential_file).credentials
expect(creds.access_key_id).to eq('ACCESS_KEY_0')
expect(creds.secret_access_key).to eq('SECRET_KEY_0')
expect(creds.session_token).to eq('TOKEN_0')
end

it 'supports fetching profiles from ENV' do
stub_const('ENV', { 'AWS_PROFILE' => 'barprofile' })
creds = SharedCredentials.new(path:mock_credential_file).credentials
creds = SharedCredentials.new(path: mock_credential_file).credentials
expect(creds.access_key_id).to eq('ACCESS_KEY_2')
expect(creds.secret_access_key).to eq('SECRET_KEY_2')
expect(creds.session_token).to eq('TOKEN_2')
Expand All @@ -39,34 +38,35 @@ module Aws
stub_const('ENV', { 'AWS_PROFILE' => 'barporfile' })
creds = SharedCredentials.new(
path: mock_credential_file,
profile_name: 'fooprofile').credentials
profile_name: 'fooprofile'
).credentials
expect(creds.access_key_id).to eq('ACCESS_KEY_1')
expect(creds.secret_access_key).to eq('SECRET_KEY_1')
expect(creds.session_token).to eq('TOKEN_1')
end

it 'raises when a path does not exist' do
msg = /^Profile `doesnotexist' not found in \/no\/file\/here/
expect {
msg = %r{^Profile `doesnotexist' not found in /no/file/here}
expect do
SharedCredentials.new(
path: '/no/file/here',
profile_name: 'doesnotexist'
)
}.to raise_error(Errors::NoSuchProfileError, msg)
end.to raise_error(Errors::NoSuchProfileError, msg)
end

it 'raises when a profile does not exist' do
msg = /^Profile `doesnotexist' not found in .+mock_shared_credentials/
expect {
expect do
SharedCredentials.new(
path: mock_credential_file,
profile_name: 'doesnotexist'
)
}.to raise_error(Errors::NoSuchProfileError, msg)
end.to raise_error(Errors::NoSuchProfileError, msg)
end

it 'is set when credentials is valid' do
creds = SharedCredentials.new(path:mock_credential_file)
creds = SharedCredentials.new(path: mock_credential_file)
expect(creds.set?).to eq(true)
end

Expand All @@ -79,13 +79,13 @@ module Aws
end

it 'supports inline comments with the profile' do
file = <<-FILE
[default] # comment
aws_access_key_id=commented-akid
aws_secret_access_key=commented-secret
file = <<~FILE
[default] # comment
aws_access_key_id=commented-akid
aws_secret_access_key=commented-secret
FILE
allow(File).to receive(:read).and_return(file)
creds = SharedCredentials.new(path:mock_credential_file).credentials
creds = SharedCredentials.new(path: mock_credential_file).credentials
expect(creds.access_key_id).to eq('commented-akid')
expect(creds.secret_access_key).to eq('commented-secret')
end
Expand All @@ -95,23 +95,23 @@ module Aws
config_enabled: true,
credentials_path: mock_credential_file,
config_path: mock_config_file,
profile_name: "creds_from_cfg"
profile_name: 'creds_from_cfg'
)
creds = SharedCredentials.new.credentials
expect(creds.access_key_id).to eq("ACCESS_KEY_SC0")
expect(creds.secret_access_key).to eq("SECRET_KEY_SC0")
expect(creds.access_key_id).to eq('ACCESS_KEY_SC0')
expect(creds.secret_access_key).to eq('SECRET_KEY_SC0')
end

it 'properly falls back when credentials incomplete' do
Aws.shared_config.fresh(
config_enabled: true,
credentials_path: mock_credential_file,
config_path: mock_config_file,
profile_name: "incomplete_cred"
profile_name: 'incomplete_cred'
)
creds = SharedCredentials.new.credentials
expect(creds.access_key_id).to eq("ACCESS_KEY_SC1")
expect(creds.secret_access_key).to eq("SECRET_KEY_SC1")
expect(creds.access_key_id).to eq('ACCESS_KEY_SC1')
expect(creds.secret_access_key).to eq('SECRET_KEY_SC1')
end

it 'will source from credentials over config' do
Expand All @@ -121,20 +121,72 @@ module Aws
config_path: mock_config_file
)
creds = SharedCredentials.new.credentials
expect(creds.access_key_id).to eq("ACCESS_KEY_0")
expect(creds.secret_access_key).to eq("SECRET_KEY_0")
expect(creds.access_key_id).to eq('ACCESS_KEY_0')
expect(creds.secret_access_key).to eq('SECRET_KEY_0')
end

it 'will ignore incomplete credentials' do
Aws.shared_config.fresh(
config_enabled: true,
credentials_path: mock_credential_file,
config_path: mock_config_file,
profile_name: "incomplete_cfg"
profile_name: 'incomplete_cfg'
)
creds = SharedCredentials.new.credentials
expect(creds).to eq(nil)
end

# Refreshing credentials tests

it 'refreshes credentials when near expiration' do
refresh_interval = 2 # Short interval for testing
creds = SharedCredentials.new(path: mock_credential_file, refresh_interval: refresh_interval,
enable_refresh: true)
allow(creds).to receive(:refresh).and_call_original

# Mock expiration logic by setting expiration time in the past
allow(creds).to receive(:near_expiration?).and_return(true)

creds.refresh!
refreshed_access_key = creds.credentials.access_key_id
expect(creds).to have_received(:refresh).at_least(:once)
expect(refreshed_access_key).to eq('ACCESS_KEY_0')
end

it 'does not refresh credentials if not near expiration' do
refresh_interval = 10 # Longer interval for testing
creds = SharedCredentials.new(
path: mock_credential_file,
refresh_interval: refresh_interval,
enable_refresh: true
)

# Capture the initial access key
initial_access_key = creds.credentials.access_key_id

allow(creds).to receive(:near_expiration?).and_return(false)

# This will force a refresh! Call
creds.refresh!

refreshed_access_key = creds.credentials.access_key_id

# Check that the access key has not changed, meaning no refresh occurred if not near expiration
expect(initial_access_key).to eq(refreshed_access_key)
end

it 'does not refresh credentials when enable_refresh is false' do
refresh_interval = 2
creds = SharedCredentials.new(path: mock_credential_file, refresh_interval: refresh_interval,
enable_refresh: false)

allow(creds).to receive(:refresh).and_call_original

# Mock expiration logic by setting expiration time in the past
allow(creds).to receive(:near_expiration?).and_return(true)

creds.refresh!
expect(creds).to have_received(:refresh).once # Ensure refresh was called only once (during initialization)
end
end
end