diff --git a/gems/aws-sdk-core/CHANGELOG.md b/gems/aws-sdk-core/CHANGELOG.md index 6987342bafe..0f48a9c4433 100644 --- a/gems/aws-sdk-core/CHANGELOG.md +++ b/gems/aws-sdk-core/CHANGELOG.md @@ -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) ------------------ diff --git a/gems/aws-sdk-core/lib/aws-sdk-core/shared_credentials.rb b/gems/aws-sdk-core/lib/aws-sdk-core/shared_credentials.rb index 836dd3f6273..fb6d1a0f158 100644 --- a/gems/aws-sdk-core/lib/aws-sdk-core/shared_credentials.rb +++ b/gems/aws-sdk-core/lib/aws-sdk-core/shared_credentials.rb @@ -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 @@ -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 + @refresh_interval = options[:refresh_interval] || 10 * 60 # Default refresh interval: 10 minutes + @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? - !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 diff --git a/gems/aws-sdk-core/spec/aws/shared_credentials_spec.rb b/gems/aws-sdk-core/spec/aws/shared_credentials_spec.rb index f05d08eea77..320a4798434 100644 --- a/gems/aws-sdk-core/spec/aws/shared_credentials_spec.rb +++ b/gems/aws-sdk-core/spec/aws/shared_credentials_spec.rb @@ -4,24 +4,23 @@ 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') @@ -29,7 +28,7 @@ module Aws 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') @@ -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 @@ -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 @@ -95,11 +95,11 @@ 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 @@ -107,11 +107,11 @@ module Aws 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 @@ -121,8 +121,8 @@ 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 @@ -130,11 +130,63 @@ module Aws 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