Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gems/aws-sdk-cognitoidentity/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
Unreleased Changes
------------------

* Issue - Remove duplicate `before_refresh` callback during credential refresh.

1.93.0 (2026-09-11)
------------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ module CognitoIdentity
# to be refreshed and it has access to the CognitoIdentityCredentials object.
class CognitoIdentityCredentials
include CredentialProvider
include RefreshingCredentials
include ResilientRefreshingCredentials

# @param [Hash] options
# @option options [String] :identity_id the Cognito identity_id. Required
Expand Down Expand Up @@ -83,7 +83,6 @@ def initialize(options = {})
@identity_id = options.delete(:identity_id)
@custom_role_arn = options.delete(:custom_role_arn)
@logins = options.delete(:logins) || {}
@async_refresh = false

client_opts = {}
options.each_pair { |k, v| client_opts[k] = v unless CLIENT_EXCLUDE_OPTIONS.include?(k) }
Expand Down Expand Up @@ -116,8 +115,6 @@ def identity_id
private

def refresh
@before_refresh&.call(self)

resp = @client.get_credentials_for_identity(
identity_id: identity_id,
custom_role_arn: @custom_role_arn,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ module CognitoIdentity
end
let(:resp) { double('client-resp', credentials: cognito_creds) }

# credential lifecycle fetches during construction
before do
allow(client).to receive(:get_credentials_for_identity).and_return(resp)
end

describe '#initialize' do

it 'constructs a client with passed arguments when not given' do
Expand Down
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 - Supported AWS credential providers now continue to use cached credentials after refresh failures and retry refresh with backoff. This improves resilience to temporary credential source outages and standardizes credential refresh timing. During an outage, requests may reach the service and return authentication errors instead of failing client-side during credential refresh. See [REFERENCE_PAGE_URL] for details and the full list of providers.

3.257.0 (2026-09-14)
------------------

Expand Down
1 change: 1 addition & 0 deletions gems/aws-sdk-core/lib/aws-sdk-core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ module Aws
autoload :Credentials, 'aws-sdk-core/credentials'
autoload :CredentialProvider, 'aws-sdk-core/credential_provider'
autoload :RefreshingCredentials, 'aws-sdk-core/refreshing_credentials'
autoload :ResilientRefreshingCredentials, 'aws-sdk-core/resilient_refreshing_credentials'
autoload :AssumeRoleCredentials, 'aws-sdk-core/assume_role_credentials'
autoload :AssumeRoleWebIdentityCredentials, 'aws-sdk-core/assume_role_web_identity_credentials'
autoload :CredentialProviderChain, 'aws-sdk-core/credential_provider_chain'
Expand Down
21 changes: 19 additions & 2 deletions gems/aws-sdk-core/lib/aws-sdk-core/assume_role_credentials.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ module Aws
class AssumeRoleCredentials

include CredentialProvider
include RefreshingCredentials
include ResilientRefreshingCredentials

# @option options [required, String] :role_arn
# @option options [required, String] :role_session_name
Expand Down Expand Up @@ -49,7 +49,6 @@ def initialize(options = {})
end
end
@client = client_opts[:client] || STS::Client.new(client_opts)
@async_refresh = true
@metrics = ['CREDENTIALS_STS_ASSUME_ROLE']
super
end
Expand All @@ -60,8 +59,26 @@ def initialize(options = {})
# @return [Hash]
attr_reader :assume_role_params

# STS error codes that indicate a misconfiguration (bad policy, denied
# access, disabled region, etc). Retrying will not resolve them, so they
# are raised immediately rather than backed off.
# @api private
NON_RECOVERABLE_ERROR_CODES = %w[
AccessDenied
IDPRejectedClaim
InvalidIdentityToken
MalformedPolicyDocument
PackedPolicyTooLarge
RegionDisabled
].freeze

private

def non_recoverable_error?(error)
error.is_a?(Aws::Errors::ServiceError) &&
NON_RECOVERABLE_ERROR_CODES.include?(error.code)
end

def refresh
resp = @client.assume_role(@assume_role_params)
creds = resp.credentials
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ module Aws
class AssumeRoleWebIdentityCredentials

include CredentialProvider
include RefreshingCredentials
include ResilientRefreshingCredentials

# @param [Hash] options
# @option options [required, String] :role_arn the IAM role
Expand All @@ -47,7 +47,6 @@ def initialize(options = {})
client_opts = {}
@assume_role_web_identity_params = {}
@token_file = options.delete(:web_identity_token_file)
@async_refresh = true
options.each_pair do |key, value|
if self.class.assume_role_web_identity_options.include?(key)
@assume_role_web_identity_params[key] = value
Expand All @@ -68,8 +67,26 @@ def initialize(options = {})
# @return [STS::Client]
attr_reader :client

# STS error codes that indicate a misconfiguration (bad policy, rejected
# or invalid token, disabled region, etc). Retrying will not resolve them,
# so they are raised immediately rather than backed off.
# @api private
NON_RECOVERABLE_ERROR_CODES = %w[
AccessDenied
IDPRejectedClaim
InvalidIdentityToken
MalformedPolicyDocument
PackedPolicyTooLarge
RegionDisabled
].freeze

private

def non_recoverable_error?(error)
error.is_a?(Aws::Errors::ServiceError) &&
NON_RECOVERABLE_ERROR_CODES.include?(error.code)
end

def refresh
# read from token file everytime it refreshes
@assume_role_web_identity_params[:web_identity_token] = _token_from_file(@token_file)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,9 @@ def instance_profile_credentials(options)
elsif !(ENV.fetch('AWS_EC2_METADATA_DISABLED', 'false').downcase == 'true')
InstanceProfileCredentials.new(options.merge(profile: profile_name))
end
rescue Errors::MissingCredentialsError
# credential source was unreachable on initial fetch, skip so chain moves on
nil
end

def assume_role_with_profile(options, profile_name)
Expand Down
32 changes: 10 additions & 22 deletions gems/aws-sdk-core/lib/aws-sdk-core/ecs_credentials.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module Aws
# ec2 = Aws::EC2::Client.new(credentials: ecs_credentials)
class ECSCredentials
include CredentialProvider
include RefreshingCredentials
include ResilientRefreshingCredentials

# @api private
class Non200Response < RuntimeError
Expand Down Expand Up @@ -86,7 +86,6 @@ def initialize(options = {})
@http_read_timeout = options[:http_read_timeout] || 5
@http_debug_output = options[:http_debug_output]
@backoff = backoff(options[:backoff])
@async_refresh = false
@metrics = ['CREDENTIALS_HTTP']
super
end
Expand Down Expand Up @@ -190,37 +189,26 @@ def backoff(backoff)
end

def refresh
# Retry loading credentials up to 3 times is the instance metadata
# service is responding but is returning invalid JSON documents
# in response to the GET profile credentials call.

retry_errors([Aws::Json::ParseError, StandardError], max_retries: 3) do
c = Aws::Json.load(get_credentials.to_s)
@credentials = Credentials.new(
c['AccessKeyId'],
c['SecretAccessKey'],
c['Token']
)
@expiration = c['Expiration'] ? Time.iso8601(c['Expiration']) : nil
# Retry loading credentials up to 3 times if the container credential
# service is responding but is returning invalid JSON documents in
# response to the GET credentials call.
c = retry_errors([Aws::Json::ParseError], max_retries: 3) do
Aws::Json.load(retrieve_credentials.to_s)
end
@credentials = Credentials.new(c['AccessKeyId'], c['SecretAccessKey'], c['Token'])
@expiration = c['Expiration'] ? Time.iso8601(c['Expiration']) : nil
rescue Aws::Json::ParseError
raise Aws::Errors::MetadataParserError
end

def get_credentials
def retrieve_credentials
# Retry loading credentials a configurable number of times if
# the instance metadata service is not responding.

# the container credential service is not responding.
retry_errors(NETWORK_ERRORS, max_retries: @retries) do
open_connection do |conn|
http_get(conn, @credential_path)
end
end
rescue TokenFileReadError, InvalidTokenError
raise
rescue StandardError => e
warn("Error retrieving ECS Credentials: #{e.message}")
'{}'
end

def fetch_authorization_token
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ module Aws
# @see https://docs.aws.amazon.com/sdkref/latest/guide/feature-imds-credentials.html IMDS Credential Provider
class InstanceProfileCredentials
include CredentialProvider
include RefreshingCredentials
include ResilientRefreshingCredentials

# @api private
class Non200Response < RuntimeError
Expand Down Expand Up @@ -97,9 +97,7 @@ def initialize(options = {})
@retries = options[:retries] || 1
@token_ttl = options[:token_ttl] || 21_600

@async_refresh = false
@imds_v1_fallback = false
@no_refresh_until = nil
@token = nil
@metrics = ['CREDENTIALS_IMDS']
super
Expand Down Expand Up @@ -181,65 +179,35 @@ def resolve_backoff(backoff)
end

def refresh
if @no_refresh_until && @no_refresh_until > Time.now
warn_expired_credentials
return
end

new_creds =
begin
# Retry loading credentials up to 3 times is the instance metadata
# service is responding but is returning invalid JSON documents
# in response to the GET profile credentials call.
retry_errors([Aws::Json::ParseError], max_retries: 3) do
Aws::Json.load(retrieve_credentials.to_s)
end
rescue Aws::Json::ParseError
raise Aws::Errors::MetadataParserError
end

if @credentials&.set? && empty_credentials?(new_creds)
# credentials are already set, but there was an error getting new credentials
# so don't update the credentials and use stale ones (static stability)
@no_refresh_until = Time.now + rand(300..360)
warn_expired_credentials
else
# credentials are empty or successfully retrieved, update them
update_credentials(new_creds)
# Retry loading credentials up to 3 times if the instance metadata
# service is responding but is returning invalid JSON documents in
# response to the GET profile credentials call.
c = retry_errors([Aws::Json::ParseError], max_retries: 3) do
Aws::Json.load(retrieve_credentials.to_s)
end
@credentials = Credentials.new(c['AccessKeyId'], c['SecretAccessKey'], c['Token'])
@expiration = c['Expiration'] ? Time.iso8601(c['Expiration']) : nil
rescue Aws::Json::ParseError
raise Aws::Errors::MetadataParserError
end

def retrieve_credentials
# Retry loading credentials a configurable number of times if
# the instance metadata service is not responding.
begin
retry_errors(NETWORK_ERRORS, max_retries: @retries) do
open_connection do |conn|
# attempt to fetch token to start secure flow first
# and rescue to failover
fetch_token(conn) unless @imds_v1_fallback || (@token && !@token.expired?)
retry_errors(NETWORK_ERRORS, max_retries: @retries) do
open_connection do |conn|
# attempt to fetch token to start secure flow first
# and rescue to failover
fetch_token(conn) unless @imds_v1_fallback || (@token && !@token.expired?)

# disable insecure flow if we couldn't get token and imds v1 is disabled
raise TokenRetrivalError if @token.nil? && @disable_imds_v1
# disable insecure flow if we couldn't get token and imds v1 is disabled
raise TokenRetrivalError if @token.nil? && @disable_imds_v1

fetch_credentials(conn)
end
fetch_credentials(conn)
end
rescue StandardError => e
warn("Error retrieving instance profile credentials: #{e}")
'{}'
end
end

def update_credentials(creds)
@credentials = Credentials.new(creds['AccessKeyId'], creds['SecretAccessKey'], creds['Token'])
@expiration = creds['Expiration'] ? Time.iso8601(creds['Expiration']) : nil
return unless @expiration && @expiration < Time.now

@no_refresh_until = Time.now + rand(300..360)
warn_expired_credentials
end

def fetch_token(conn)
created_time = Time.now
token_value, ttl = http_put(conn)
Expand Down Expand Up @@ -326,15 +294,6 @@ def retry_errors(error_classes, options = {}, &_block)
end
end

def warn_expired_credentials
warn('Attempting credential expiration extension due to a credential service availability issue. '\
'A refresh of these credentials will be attempted again in 5 minutes.')
end

def empty_credentials?(creds_hash)
!creds_hash['AccessKeyId'] || creds_hash['AccessKeyId'].empty?
end

# @api private
# Token used to fetch IMDS profile and credentials
class Token
Expand Down
12 changes: 9 additions & 3 deletions gems/aws-sdk-core/lib/aws-sdk-core/login_credentials.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ module Aws
# be constructed with additional options that were provided.
class LoginCredentials
include CredentialProvider
include RefreshingCredentials
include ResilientRefreshingCredentials

# @option options [required, String] :login_session An opaque string
# used to determine the cache file location. This value can be found
Expand All @@ -34,7 +34,6 @@ def initialize(options = {})
@client = Signin::Client.new(client_opts.merge(credentials: nil))
end
@metrics = ['CREDENTIALS_LOGIN']
@async_refresh = true
super
end

Expand All @@ -47,7 +46,8 @@ def refresh
# First reload the token from disk to ensure it hasn't been refreshed externally
token_json = read_cached_token
update_creds(token_json['accessToken'])
return if @credentials && @expiration && !near_expiration?(sync_expiration_length)
# if the reloaded token is fresh use it without contacting Sign-In
return unless refresh_needed?

# Using OpenSSL 3.6.0 may result in errors like "certificate verify failed (unable to get certificate CRL)."
# A recommended workaround is to use OpenSSL version < 3.6.0 or requiring the openssl gem with a version of at
Expand All @@ -67,6 +67,12 @@ def refresh
'Login token is invalid and failed to refresh. Please reauthenticate.'
end

# A missing, unparseable, or malformed login token requires the user to
# reauthenticate, so it must be raised immediately rather than retried.
def non_recoverable_error?(error)
error.is_a?(Errors::InvalidLoginToken) || error.is_a?(ArgumentError)
end

def read_cached_token
cached_token = JSON.load_file(login_cache_file)
validate_cached_token(cached_token)
Expand Down
Loading
Loading