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
98 changes: 69 additions & 29 deletions gems/aws-sdk-core/lib/aws-sdk-core/shared_credentials.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
# frozen_string_literal: true

require_relative 'ini_parser'
require_relative 'refreshing_credentials'

module Aws
class SharedCredentials

include CredentialProvider
include RefreshingCredentials

# @api private
KEY_MAP = {
zendesk-duncanboynton marked this conversation as resolved.
Show resolved Hide resolved
'aws_access_key_id' => 'access_key_id',
'aws_secret_access_key' => 'secret_access_key',
'aws_session_token' => 'session_token',
'aws_session_token' => 'session_token'
}

# Constructs a new SharedCredentials object. This will load static
Expand All @@ -25,57 +26,96 @@ class SharedCredentials
# You may access the resolved credentials through
# `client.config.credentials`.
#
# @option [String] :path Path to the shared file. Defaults
# @option [String] :path Path to the shared file. Defaults
# to "#{Dir.home}/.aws/credentials".
#
# @option [String] :profile_name Defaults to 'default' or
# `ENV['AWS_PROFILE']`.
#
# @option [Integer] :refresh_interval The duration (in seconds) before the
# credentials are considered near expiration and should be refreshed.
# Defaults to 10 minutes (600 seconds).
#
# @option [Boolean] :enable_refresh If true, the credentials will be automatically
# refreshed when they are near expiration. Defaults to false.
#
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] || Aws.shared_config.credentials_path
@profile_name = options[:profile_name] || ENV['AWS_PROFILE'] || Aws.shared_config.profile_name

super(options) # This will call RefreshingCredentials#initialize
zendesk-duncanboynton marked this conversation as resolved.
Show resolved Hide resolved

refresh # Initially load credentials
zendesk-duncanboynton marked this conversation as resolved.
Show resolved Hide resolved
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]
# @return [Credentials] The loaded credentials
attr_reader :credentials

# @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)
# Refreshes credentials from the shared credentials file.
def refresh
shared_config = Aws.shared_config
zendesk-duncanboynton marked this conversation as resolved.
Show resolved Hide resolved

if @path && @path == shared_config.credentials_path
# Use credentials from shared config if paths match
@credentials = shared_config.credentials(profile: @profile_name)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this actually refresh credentials? Aws.shared_config I don't think will re-load/parse the config files, so I'm not sure you would actually get new credentials from this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would have to be SharedConfig.new, always, right?

else
# Load credentials from specified path and profile
config = SharedConfig.new(
credentials_path: @path,
profile_name: @profile_name
)
@credentials = config.credentials(profile: @profile_name)
end

if @credentials && @credentials.set?
# Credentials successfully loaded and set
@access_key_id = @credentials.access_key_id
@secret_access_key = @credentials.secret_access_key
@session_token = @credentials.session_token
@expiration = Time.now + @refresh_interval
else
# Incomplete or missing credentials
@credentials = nil
@access_key_id = nil
@secret_access_key = nil
@session_token = nil
@expiration = nil
end
zendesk-duncanboynton marked this conversation as resolved.
Show resolved Hide resolved
end

# For testing purposes to check the refresh logic
# This method triggers the internal refresh logic as if the credentials
# were near expiration.
def force_refresh_check
zendesk-duncanboynton marked this conversation as resolved.
Show resolved Hide resolved
refresh_if_near_expiration! if @enable_refresh
end

private

def sync_expiration_length
zendesk-duncanboynton marked this conversation as resolved.
Show resolved Hide resolved
@refresh_interval || self.class::SYNC_EXPIRATION_LENGTH
end

def async_expiration_length
@refresh_interval || self.class::ASYNC_EXPIRATION_LENGTH
end
end
end
99 changes: 69 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,59 @@ 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)

initial_access_key = creds.credentials.access_key_id
allow(creds).to receive(:refresh).and_call_original

# Simulate time passing to near expiration
sleep(refresh_interval + 1)
zendesk-duncanboynton marked this conversation as resolved.
Show resolved Hide resolved

creds.force_refresh_check
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)

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

# Check credentials before expiration
initial_access_key = creds.credentials.access_key_id

sleep(2)

creds.force_refresh_check

# Check credentials to ensure they are not refreshed
refreshed_access_key = creds.credentials.access_key_id

expect(creds).to have_received(:refresh).once # Ensure refresh was called only once (during initialization)
expect(initial_access_key).to eq(refreshed_access_key)
end
end
end
Loading