Skip to content

Commit

Permalink
implemented refreshing credentials in SharedCredentials
Browse files Browse the repository at this point in the history
  • Loading branch information
zendesk-duncanboynton committed Jul 31, 2024
1 parent e812244 commit b152131
Show file tree
Hide file tree
Showing 3 changed files with 140 additions and 59 deletions.
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 = {
'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
@refresh_interval = options[:refresh_interval] || 10 * 60 # Default refresh interval: 10 minutes
@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

refresh # Initially load credentials
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?
!path.nil? && File.exist?(path) && File.readable?(path)
# Refreshes credentials from the shared credentials file.
def refresh
shared_config = Aws.shared_config

if @path && @path == shared_config.credentials_path
# Use credentials from shared config if paths match
@credentials = shared_config.credentials(profile: @profile_name)
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
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
refresh_if_near_expiration! if @enable_refresh
end

private

def sync_expiration_length
@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)

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

0 comments on commit b152131

Please sign in to comment.