From 3fefc4c4583d8194f4223b6a027e898dd0c27d5f Mon Sep 17 00:00:00 2001 From: kai lin Date: Wed, 16 Sep 2026 16:39:56 -0400 Subject: [PATCH] Add shared credential-refresh lifecycle to AWSCredentialsProvider --- .../core/auth/CredentialsRefreshProvider.h | 57 +++ .../aws/core/auth/CredentialsRefreshResult.h | 58 +++ .../aws/core/internal/CredentialsRefresh.h | 393 ++++++++++++++++++ .../auth/CredentialsRefreshProvider.cpp | 56 +++ tests/aws-cpp-sdk-core-tests/CMakeLists.txt | 3 +- .../resources/credentials-refresh-tests.json | 374 +++++++++++++++++ .../utils/CredentialsRefreshTest.cpp | 230 ++++++++++ 7 files changed, 1170 insertions(+), 1 deletion(-) create mode 100644 src/aws-cpp-sdk-core/include/aws/core/auth/CredentialsRefreshProvider.h create mode 100644 src/aws-cpp-sdk-core/include/aws/core/auth/CredentialsRefreshResult.h create mode 100644 src/aws-cpp-sdk-core/include/aws/core/internal/CredentialsRefresh.h create mode 100644 src/aws-cpp-sdk-core/source/auth/CredentialsRefreshProvider.cpp create mode 100644 tests/aws-cpp-sdk-core-tests/resources/credentials-refresh-tests.json create mode 100644 tests/aws-cpp-sdk-core-tests/utils/CredentialsRefreshTest.cpp diff --git a/src/aws-cpp-sdk-core/include/aws/core/auth/CredentialsRefreshProvider.h b/src/aws-cpp-sdk-core/include/aws/core/auth/CredentialsRefreshProvider.h new file mode 100644 index 000000000000..97eda3f3ad72 --- /dev/null +++ b/src/aws-cpp-sdk-core/include/aws/core/auth/CredentialsRefreshProvider.h @@ -0,0 +1,57 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace Aws +{ + namespace Internal { class CredentialsRefreshStateImpl; } + namespace Auth + { + /** + * A single credential fetch against the underlying source, classified fresh/recoverable/ + * non-recoverable. Providers implement this to be wrapped by CredentialsRefreshProvider; + * the classification drives serve-last-good and backoff. + */ + class AWS_CORE_API CredentialsSource + { + public: + virtual ~CredentialsSource() = default; + virtual Aws::Auth::RefreshResult FetchCredentials() = 0; + }; + + /** + * Wraps a CredentialsSource with the shared credentials-refresh lifecycle (caching, + * advisory/mandatory windows, jittered backoff, serve-last-good, single in-flight refresh, + * invalidation). The wrap is applied only when AWS_NEW_CREDENTIAL_REFRESH_2026 is on; when + * off the source provider is used directly, preserving legacy behavior. + */ + class AWS_CORE_API CredentialsRefreshProvider : public AWSCredentialsProvider + { + public: + explicit CredentialsRefreshProvider(std::shared_ptr source); + ~CredentialsRefreshProvider() override; + + AWSCredentials GetAWSCredentials() override; + + // Marks cached credentials for refresh after a service rejects them (ExpiredToken/InvalidToken). + virtual void Invalidate(); + + protected: + // Clock for the refresh windows/backoff; defaults to the system clock, overridable for tests. + virtual Aws::Utils::DateTime CurrentTime() const; + + private: + std::shared_ptr m_source; + std::unique_ptr m_refreshState; + }; + } // namespace Auth +} // namespace Aws diff --git a/src/aws-cpp-sdk-core/include/aws/core/auth/CredentialsRefreshResult.h b/src/aws-cpp-sdk-core/include/aws/core/auth/CredentialsRefreshResult.h new file mode 100644 index 000000000000..171462fd24ed --- /dev/null +++ b/src/aws-cpp-sdk-core/include/aws/core/auth/CredentialsRefreshResult.h @@ -0,0 +1,58 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace Aws +{ + namespace Auth + { + // Result of one credential-source fetch: the return type of CredentialsSource::FetchCredentials(), + // so it is public (the refresh engine that consumes it stays internal). + template + class RefreshResult + { + public: + enum class Status + { + Success, // fresh credentials obtained + Recoverable, // transient failure: back off and keep serving cache + NonRecoverable // will not succeed without customer action: raise + briefly cache the error + }; + + RefreshResult() = default; + RefreshResult(Status status, CredentialsT credentials, Aws::Crt::Optional expiration, + Aws::String errorMessage) + : status(status), credentials(std::move(credentials)), + expiration(std::move(expiration)), errorMessage(std::move(errorMessage)) {} + + // Named constructors. Only Fresh() carries an expiration. + static RefreshResult Fresh(CredentialsT credentials, Aws::Utils::DateTime expiration) + { + return RefreshResult(Status::Success, std::move(credentials), + Aws::Crt::Optional(expiration), {}); + } + static RefreshResult Recoverable(Aws::String errorMessage = {}) + { + return RefreshResult(Status::Recoverable, CredentialsT{}, {}, std::move(errorMessage)); + } + static RefreshResult NonRecoverable(Aws::String errorMessage) + { + return RefreshResult(Status::NonRecoverable, CredentialsT{}, {}, std::move(errorMessage)); + } + + Status status{Status::Recoverable}; + CredentialsT credentials{}; + Aws::Crt::Optional expiration; // engaged only when status == Success + Aws::String errorMessage; + }; + } // namespace Auth +} // namespace Aws diff --git a/src/aws-cpp-sdk-core/include/aws/core/internal/CredentialsRefresh.h b/src/aws-cpp-sdk-core/include/aws/core/internal/CredentialsRefresh.h new file mode 100644 index 000000000000..086de00b3ab5 --- /dev/null +++ b/src/aws-cpp-sdk-core/include/aws/core/internal/CredentialsRefresh.h @@ -0,0 +1,393 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Aws +{ + namespace Internal + { + // Refresh windows. Advisory scales with lifetime; mandatory is fixed at 1 min before expiry. + constexpr std::chrono::milliseconds CREDENTIAL_MANDATORY_WINDOW = std::chrono::minutes(1); + constexpr std::chrono::milliseconds CREDENTIAL_ADVISORY_WINDOW_SHORT = std::chrono::minutes(5); // lifetime <= 20 min + constexpr std::chrono::milliseconds CREDENTIAL_ADVISORY_WINDOW_MEDIUM = std::chrono::minutes(15); // 20 min < lifetime < 90 min + constexpr std::chrono::milliseconds CREDENTIAL_ADVISORY_WINDOW_LONG = std::chrono::minutes(60); // lifetime >= 90 min + constexpr std::chrono::milliseconds CREDENTIAL_ADVISORY_LIFETIME_LOW = std::chrono::minutes(20); + constexpr std::chrono::milliseconds CREDENTIAL_ADVISORY_LIFETIME_HIGH = std::chrono::minutes(90); + + // Backoff after a failed refresh: jittered 5-10 min before retrying the source. + constexpr std::chrono::milliseconds CREDENTIALS_REFRESH_BACKOFF_MIN = std::chrono::minutes(5); + constexpr std::chrono::milliseconds CREDENTIALS_REFRESH_BACKOFF_MAX = std::chrono::minutes(10); + + // Non-recoverable errors: briefly cache the failure (1-5 s) so a retry loop can't hammer the source. + constexpr std::chrono::milliseconds CREDENTIAL_NONRECOVERABLE_CACHE_MIN = std::chrono::seconds(1); + constexpr std::chrono::milliseconds CREDENTIAL_NONRECOVERABLE_CACHE_MAX = std::chrono::seconds(5); + + enum class CredentialsRefreshPhase + { + NoCredentials, // nothing cached yet; the first caller fetches while others block + Valid, // fresh; return cached, no refresh + Advisory, // soft window; refresh is due, but concurrent callers keep serving cached creds + Mandatory, // hard window; block the caller for a refresh + Expired // past expiration; treated like Mandatory + }; + + // Advisory window for a given credential lifetime. + inline std::chrono::milliseconds ComputeAdvisoryWindow(std::chrono::milliseconds lifetime) + { + if (lifetime <= CREDENTIAL_ADVISORY_LIFETIME_LOW) + { + return CREDENTIAL_ADVISORY_WINDOW_SHORT; + } + if (lifetime < CREDENTIAL_ADVISORY_LIFETIME_HIGH) + { + return CREDENTIAL_ADVISORY_WINDOW_MEDIUM; + } + return CREDENTIAL_ADVISORY_WINDOW_LONG; + } + + // Classify the phase at `now`. Advisory is clamped >= mandatory so the mandatory boundary never + // precedes the advisory one. + inline CredentialsRefreshPhase ClassifyRefreshPhase(bool hasCredentials, + const Aws::Utils::DateTime& now, + const Aws::Utils::DateTime& expiration, + std::chrono::milliseconds advisoryWindow) + { + if (!hasCredentials) + { + return CredentialsRefreshPhase::NoCredentials; + } + + const int64_t nowMs = now.Millis(); + const int64_t expirationMs = expiration.Millis(); + if (nowMs >= expirationMs) + { + return CredentialsRefreshPhase::Expired; + } + + if (advisoryWindow < CREDENTIAL_MANDATORY_WINDOW) + { + advisoryWindow = CREDENTIAL_MANDATORY_WINDOW; + } + const int64_t mandatoryAtMs = expirationMs - CREDENTIAL_MANDATORY_WINDOW.count(); + const int64_t advisoryAtMs = expirationMs - advisoryWindow.count(); + + if (nowMs >= mandatoryAtMs) + { + return CredentialsRefreshPhase::Mandatory; + } + if (nowMs >= advisoryAtMs) + { + return CredentialsRefreshPhase::Advisory; + } + return CredentialsRefreshPhase::Valid; + } + + // Top-level action for a GetCredentials() call, given the phase and whether a failed refresh is + // still within its backoff window. + enum class RefreshAction + { + ReturnCached, // Valid, or a needs-refresh state that is currently rate-limited + RefreshNonBlocking, // Advisory: refresh without blocking; if one is in flight, serve cache + FetchBlocking // NoCredentials (initial fetch) or Mandatory/Expired: block the caller + }; + + inline RefreshAction DecideRefreshAction(CredentialsRefreshPhase state, bool refreshRateLimited) + { + // Initial fetch always attempts, regardless of backoff: there is nothing cached to serve yet. + if (state == CredentialsRefreshPhase::NoCredentials) + { + return RefreshAction::FetchBlocking; + } + if (state == CredentialsRefreshPhase::Valid) + { + return RefreshAction::ReturnCached; + } + // A refresh is due; honor the backoff so a failing source is contacted at most once per window. + if (refreshRateLimited) + { + return RefreshAction::ReturnCached; + } + if (state == CredentialsRefreshPhase::Advisory) + { + return RefreshAction::RefreshNonBlocking; + } + return RefreshAction::FetchBlocking; // Mandatory or Expired + } + + // Scale a uniform sample jitter01 in [0, 1) onto [lo, hi) (never reaches hi exactly). + inline std::chrono::milliseconds ScaleJitter(std::chrono::milliseconds lo, + std::chrono::milliseconds hi, + double jitter01) + { + if (jitter01 < 0.0) { jitter01 = 0.0; } + if (jitter01 > 1.0) { jitter01 = 1.0; } + const int64_t span = hi.count() - lo.count(); + return lo + std::chrono::milliseconds(static_cast(jitter01 * static_cast(span))); + } + + // Backoff after a failed refresh: uniform 5-10 min. + inline std::chrono::milliseconds ComputeRefreshBackoff(double jitter01) + { + return ScaleJitter(CREDENTIALS_REFRESH_BACKOFF_MIN, CREDENTIALS_REFRESH_BACKOFF_MAX, jitter01); + } + + // Short cache of a non-recoverable error: uniform 1-5 s. + inline std::chrono::milliseconds ComputeNonRecoverableCacheDuration(double jitter01) + { + return ScaleJitter(CREDENTIAL_NONRECOVERABLE_CACHE_MIN, CREDENTIAL_NONRECOVERABLE_CACHE_MAX, jitter01); + } + + // Feature gate (dark ship): off unless AWS_NEW_CREDENTIAL_REFRESH_2026 is "true". + inline bool IsNewCredentialsRefreshEnabled() + { + return Aws::Utils::StringUtils::ToLower( + Aws::Environment::GetEnv("AWS_NEW_CREDENTIAL_REFRESH_2026").c_str()) == "true"; + } + + // Internal state-holder for the refresh lifecycle, owned by AWSCredentialsProvider. One refresh in + // flight at a time; a failed refresh never discards cached credentials. + template + class AWS_CORE_LOCAL CredentialsRefreshState + { + public: + using FetchFunction = std::function()>; + using ClockFunction = std::function; + using JitterFunction = std::function; // uniform sample in [0, 1) + + // Per-call observation of the resolution flow. + struct ResolveObservation + { + bool sourceContacted{false}; // the credential source was contacted on this call + bool rateLimited{false}; // the refresh backoff was in effect at the start of the call + bool returnedNewCredentials{false}; // the returned credentials were freshly fetched this call + bool nonRecoverable{false}; // the raised error was non-recoverable + std::chrono::milliseconds advisoryWindow{0}; // advisory window in effect for the returned creds + }; + + explicit CredentialsRefreshState(FetchFunction fetch, + ClockFunction clock = &Aws::Utils::DateTime::Now, + JitterFunction jitter = &CredentialsRefreshState::DefaultJitter) + : m_fetch(std::move(fetch)), m_clock(std::move(clock)), m_jitter(std::move(jitter)) + { + } + + // Resolve credentials through the refresh lifecycle. Returns cached (serving last-good on a + // failed refresh), or an error when nothing is cached or the error is non-recoverable. + Aws::Utils::Outcome GetCredentials(ResolveObservation* observation = nullptr) + { + ResolveObservation local; + ResolveObservation& obs = observation ? *observation : local; + obs = ResolveObservation{}; + + const Aws::Utils::DateTime now = m_clock(); + const int64_t nowMs = now.Millis(); + + CredentialsRefreshPhase state; + bool rateLimited; + bool nonRecoverableCached; + Aws::String cachedError; + CredentialsT cachedCredentials{}; + { + Aws::Utils::Threading::ReaderLockGuard guard(m_stateLock); + state = m_cached + ? ClassifyRefreshPhase(true, now, m_cached->expiration, m_cached->advisoryWindow) + : CredentialsRefreshPhase::NoCredentials; + rateLimited = IsRateLimitedLocked(nowMs); + nonRecoverableCached = NonRecoverableCachedLocked(nowMs); + if (nonRecoverableCached) { cachedError = m_cachedErrorMessage; } // copy only when it's returned + if (m_cached) + { + cachedCredentials = m_cached->credentials; + obs.advisoryWindow = m_cached->advisoryWindow; + } + } + + // rateLimited only matters once a refresh is due; report it as such. + const bool needsRefresh = state == CredentialsRefreshPhase::Advisory || + state == CredentialsRefreshPhase::Mandatory || + state == CredentialsRefreshPhase::Expired; + obs.rateLimited = needsRefresh && rateLimited; + + // A cached non-recoverable error is still live: re-raise it without contacting the source. + if (nonRecoverableCached) + { + obs.nonRecoverable = true; + return Aws::Utils::Outcome(cachedError); + } + + switch (DecideRefreshAction(state, rateLimited)) + { + case RefreshAction::ReturnCached: + return Aws::Utils::Outcome(cachedCredentials); + case RefreshAction::RefreshNonBlocking: + { + // Advisory: do not block. If another caller is already refreshing, serve the cache. + std::unique_lock gate(m_refreshGate, std::try_to_lock); + if (!gate.owns_lock()) + { + Aws::Utils::Threading::ReaderLockGuard guard(m_stateLock); + if (m_cached) + { + obs.advisoryWindow = m_cached->advisoryWindow; + return Aws::Utils::Outcome(m_cached->credentials); + } + return Aws::Utils::Outcome(cachedCredentials); + } + return RefreshUnderGate(obs); + } + case RefreshAction::FetchBlocking: + default: + { + // Initial fetch or mandatory/expired: one caller performs the refresh, others wait. + std::unique_lock gate(m_refreshGate); + return RefreshUnderGate(obs); + } + } + } + + // Mark cached credentials for refresh after a service rejected them. Sets the cached expiration + // to now so the next GetCredentials() takes the mandatory path; never discards credentials. + void Invalidate() + { + // Unconditional (state lock only, not gated on m_refreshGate) so an invalidation is never lost. + Aws::Utils::Threading::WriterLockGuard guard(m_stateLock); + if (m_cached) + { + m_cached->expiration = m_clock(); // route the next GetCredentials() through the mandatory path + } + } + + private: + static double DefaultJitter() + { + static thread_local std::mt19937 generator{std::random_device{}()}; + std::uniform_real_distribution distribution(0.0, 1.0); + return distribution(generator); + } + + bool IsRateLimitedLocked(int64_t nowMs) const + { + return m_nextRefreshAllowedAtMs != 0 && nowMs < m_nextRefreshAllowedAtMs; + } + + bool NonRecoverableCachedLocked(int64_t nowMs) const + { + return m_hasCachedError && nowMs < m_cachedErrorExpiresAtMs; + } + + // One refresh attempt; must hold m_refreshGate. The fetch runs without the state lock so + // concurrent readers keep getting the cached credentials. + Aws::Utils::Outcome RefreshUnderGate(ResolveObservation& obs) + { + { + // Re-check: another caller may have refreshed (or set the backoff) while we waited. + const Aws::Utils::DateTime now = m_clock(); + const int64_t nowMs = now.Millis(); + Aws::Utils::Threading::ReaderLockGuard guard(m_stateLock); + // Honor a cached non-recoverable error even with nothing cached (it doesn't populate m_cached). + if (NonRecoverableCachedLocked(nowMs)) + { + obs.nonRecoverable = true; + return Aws::Utils::Outcome(m_cachedErrorMessage); + } + if (m_cached) + { + const CredentialsRefreshPhase state = ClassifyRefreshPhase(true, now, m_cached->expiration, m_cached->advisoryWindow); + if (state == CredentialsRefreshPhase::Valid) + { + obs.advisoryWindow = m_cached->advisoryWindow; + return Aws::Utils::Outcome(m_cached->credentials); + } + if (IsRateLimitedLocked(nowMs)) + { + obs.rateLimited = true; + obs.advisoryWindow = m_cached->advisoryWindow; + return Aws::Utils::Outcome(m_cached->credentials); + } + } + } + + const Aws::Auth::RefreshResult result = m_fetch(); + obs.sourceContacted = true; + const int64_t nowMs = m_clock().Millis(); + + Aws::Utils::Threading::WriterLockGuard guard(m_stateLock); + if (result.status == Aws::Auth::RefreshResult::Status::Success && + result.expiration.has_value() && result.expiration->Millis() > nowMs) + { + CachedCredentials fresh; + fresh.credentials = result.credentials; + fresh.expiration = result.expiration.value(); + fresh.advisoryWindow = ComputeAdvisoryWindow(std::chrono::milliseconds(result.expiration->Millis() - nowMs)); + m_cached = std::move(fresh); + m_nextRefreshAllowedAtMs = 0; + m_hasCachedError = false; + m_cachedErrorExpiresAtMs = 0; + m_cachedErrorMessage.clear(); + obs.returnedNewCredentials = true; + obs.advisoryWindow = m_cached->advisoryWindow; + return Aws::Utils::Outcome(m_cached->credentials); + } + + if (result.status == Aws::Auth::RefreshResult::Status::NonRecoverable) + { + // Raise immediately, but briefly cache the error so a retry loop can't hammer the source. + m_hasCachedError = true; + m_cachedErrorMessage = result.errorMessage; + m_cachedErrorExpiresAtMs = nowMs + ComputeNonRecoverableCacheDuration(m_jitter()).count(); + obs.nonRecoverable = true; + return Aws::Utils::Outcome(result.errorMessage); + } + + // Recoverable failure (or a success already expired): back off and serve last-good if any. + m_nextRefreshAllowedAtMs = nowMs + ComputeRefreshBackoff(m_jitter()).count(); + if (m_cached) + { + obs.advisoryWindow = m_cached->advisoryWindow; + return Aws::Utils::Outcome(m_cached->credentials); + } + return Aws::Utils::Outcome( + result.errorMessage.empty() ? Aws::String("NoCredentialsError") : result.errorMessage); + } + + FetchFunction m_fetch; + ClockFunction m_clock; + JitterFunction m_jitter; + + // Cached credentials plus their derived timing. Held in an Optional so empty == no credentials. + struct CachedCredentials + { + CredentialsT credentials; + Aws::Utils::DateTime expiration; + std::chrono::milliseconds advisoryWindow{CREDENTIAL_ADVISORY_WINDOW_MEDIUM}; + }; + + mutable Aws::Utils::Threading::ReaderWriterLock m_stateLock; // guards the cached fields below + std::mutex m_refreshGate; // one refresh in flight at a time + + Aws::Crt::Optional m_cached; // empty == no credentials cached yet + int64_t m_nextRefreshAllowedAtMs{0}; // 0 = not rate-limited + bool m_hasCachedError{false}; + Aws::String m_cachedErrorMessage; + int64_t m_cachedErrorExpiresAtMs{0}; + }; + } // namespace Internal +} // namespace Aws diff --git a/src/aws-cpp-sdk-core/source/auth/CredentialsRefreshProvider.cpp b/src/aws-cpp-sdk-core/source/auth/CredentialsRefreshProvider.cpp new file mode 100644 index 000000000000..faaaec2d8226 --- /dev/null +++ b/src/aws-cpp-sdk-core/source/auth/CredentialsRefreshProvider.cpp @@ -0,0 +1,56 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +#include +#include // full refresh engine (kept out of the public header) +#include + +using namespace Aws::Auth; +using namespace Aws::Utils; + +namespace Aws +{ + namespace Internal + { + class AWS_CORE_LOCAL CredentialsRefreshStateImpl : public CredentialsRefreshState + { + public: + using CredentialsRefreshState::CredentialsRefreshState; + }; + } +} + +static const char CREDENTIALS_REFRESH_PROVIDER_LOG_TAG[] = "CredentialsRefreshProvider"; + +CredentialsRefreshProvider::CredentialsRefreshProvider(std::shared_ptr source) + : m_source(std::move(source)), + m_refreshState(new Aws::Internal::CredentialsRefreshStateImpl( + [this]() { return m_source->FetchCredentials(); }, + [this]() { return CurrentTime(); })) +{ +} + +CredentialsRefreshProvider::~CredentialsRefreshProvider() = default; + +AWSCredentials CredentialsRefreshProvider::GetAWSCredentials() +{ + auto outcome = m_refreshState->GetCredentials(); + if (!outcome.IsSuccess()) + { + AWS_LOGSTREAM_ERROR(CREDENTIALS_REFRESH_PROVIDER_LOG_TAG, "Credential refresh failed, returning empty credentials: " << outcome.GetError()); + return AWSCredentials(); + } + return outcome.GetResult(); +} + +void CredentialsRefreshProvider::Invalidate() +{ + m_refreshState->Invalidate(); +} + +DateTime CredentialsRefreshProvider::CurrentTime() const +{ + return DateTime::Now(); +} diff --git a/tests/aws-cpp-sdk-core-tests/CMakeLists.txt b/tests/aws-cpp-sdk-core-tests/CMakeLists.txt index 22bf90bfd8ce..06a399c24441 100644 --- a/tests/aws-cpp-sdk-core-tests/CMakeLists.txt +++ b/tests/aws-cpp-sdk-core-tests/CMakeLists.txt @@ -127,7 +127,8 @@ endif() target_link_libraries(${PROJECT_NAME} ${PROJECT_LIBS} ${CLIENT_LIBS}) target_compile_definitions(${PROJECT_NAME} PRIVATE - "CLOCK_SKEW_TEST_CASES_PATH=\"${CMAKE_CURRENT_SOURCE_DIR}/resources/clock-skew-test-cases.json\"") + "CLOCK_SKEW_TEST_CASES_PATH=\"${CMAKE_CURRENT_SOURCE_DIR}/resources/clock-skew-test-cases.json\"" + "CREDENTIALS_REFRESH_TEST_CASES_PATH=\"${CMAKE_CURRENT_SOURCE_DIR}/resources/credentials-refresh-tests.json\"") add_custom_command(TARGET aws-cpp-sdk-core-tests PRE_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory diff --git a/tests/aws-cpp-sdk-core-tests/resources/credentials-refresh-tests.json b/tests/aws-cpp-sdk-core-tests/resources/credentials-refresh-tests.json new file mode 100644 index 000000000000..869f6c763581 --- /dev/null +++ b/tests/aws-cpp-sdk-core-tests/resources/credentials-refresh-tests.json @@ -0,0 +1,374 @@ +[ + { + "documentation": "Valid cached credentials: no refresh is attempted and the caller receives the cached credentials.", + "given": { "cachedCredentials": "valid" }, + "steps": [ + { + "type": "getCredentials", + "expected": { "result": "cachedCredentials", "sourceContacted": false, "rateLimited": false } + } + ] + }, + { + "documentation": "Advisory window, refresh succeeds: the caller receives the newly refreshed credentials.", + "given": { "cachedCredentials": "advisory" }, + "steps": [ + { + "type": "getCredentials", + "response": "freshCredentials", + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + }, + { + "documentation": "Advisory window, refresh fails: the resolver applies the refresh backoff and the caller receives the existing cached credentials.", + "given": { "cachedCredentials": "advisory" }, + "steps": [ + { + "type": "getCredentials", + "response": "error", + "expected": { "result": "cachedCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + }, + { + "documentation": "Mandatory window, refresh succeeds: the caller receives the newly refreshed credentials.", + "given": { "cachedCredentials": "mandatory" }, + "steps": [ + { + "type": "getCredentials", + "response": "freshCredentials", + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + }, + { + "documentation": "Mandatory window, refresh fails: the resolver applies the refresh backoff and the caller receives the cached credentials.", + "given": { "cachedCredentials": "mandatory" }, + "steps": [ + { + "type": "getCredentials", + "response": "error", + "expected": { "result": "cachedCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + }, + { + "documentation": "Expired credentials are refreshed successfully: the caller receives the newly refreshed credentials.", + "given": { "cachedCredentials": "expired" }, + "steps": [ + { + "type": "getCredentials", + "response": "freshCredentials", + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + }, + { + "documentation": "Expired credentials, refresh fails: the resolver applies the refresh backoff and the caller receives the expired cached credentials rather than raising.", + "given": { "cachedCredentials": "expired" }, + "steps": [ + { + "type": "getCredentials", + "response": "error", + "expected": { "result": "cachedCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + }, + { + "documentation": "No cached credentials and the initial fetch fails: the SDK raises, since there are no cached credentials to fall back on. The next call retries and succeeds.", + "given": { "cachedCredentials": "none" }, + "steps": [ + { + "type": "getCredentials", + "response": "error", + "expected": { "result": "noCredentialsError", "sourceContacted": true, "rateLimited": false } + }, + { + "type": "getCredentials", + "response": "freshCredentials", + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + }, + { + "documentation": "Advisory window, source returns stale credentials (Expiration at or before now): treated as a failed refresh. The resolver applies the refresh backoff and returns the existing cached credentials.", + "given": { "cachedCredentials": "advisory" }, + "steps": [ + { + "type": "getCredentials", + "response": "staleCredentials", + "expected": { "result": "cachedCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + }, + { + "documentation": "Mandatory window, source returns stale credentials: same as the advisory case, treated as a failed refresh.", + "given": { "cachedCredentials": "mandatory" }, + "steps": [ + { + "type": "getCredentials", + "response": "staleCredentials", + "expected": { "result": "cachedCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + }, + + { + "documentation": "A 10-minute credential lifetime selects the 5-minute advisory window (lifetime <= 20 minutes).", + "given": { "cachedCredentials": "none" }, + "steps": [ + { + "type": "getCredentials", + "response": "freshCredentials", + "lifetimeSeconds": 600, + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false, "advisoryWindowSeconds": 300 } + } + ] + }, + { + "documentation": "A 20.5-minute credential lifetime selects the 15-minute advisory window (lifetime > 20 and < 90 minutes).", + "given": { "cachedCredentials": "none" }, + "steps": [ + { + "type": "getCredentials", + "response": "freshCredentials", + "lifetimeSeconds": 1230, + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false, "advisoryWindowSeconds": 900 } + } + ] + }, + { + "documentation": "A 6-hour credential lifetime selects the 60-minute advisory window (lifetime >= 90 minutes).", + "given": { "cachedCredentials": "none" }, + "steps": [ + { + "type": "getCredentials", + "response": "freshCredentials", + "lifetimeSeconds": 21600, + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false, "advisoryWindowSeconds": 3600 } + } + ] + }, + { + "documentation": "After a successful refresh returns credentials with a different lifetime, the SDK recomputes the advisory window. The first credentials have a 6-hour lifetime (60-minute window); after advancing into that window, the refreshed credentials have a 10-minute lifetime (5-minute window).", + "given": { "cachedCredentials": "none" }, + "steps": [ + { + "type": "getCredentials", + "response": "freshCredentials", + "lifetimeSeconds": 21600, + "documentation": "Initial fetch returns 6-hour credentials, selecting the 60-minute advisory window.", + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false, "advisoryWindowSeconds": 3600 } + }, + { + "type": "advanceTime", + "seconds": 18060 + }, + { + "type": "getCredentials", + "response": "freshCredentials", + "lifetimeSeconds": 600, + "documentation": "59 minutes remain until expiration, inside the 60-minute advisory window, so the SDK refreshes. The new 10-minute credentials select the 5-minute advisory window.", + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false, "advisoryWindowSeconds": 300 } + } + ] + }, + { + "documentation": "A customer-configured advisory window overrides the table. Credentials with a 6-hour lifetime would map to 60 minutes, but the configured 30-minute window is used instead.", + "given": { "cachedCredentials": "none", "configuredAdvisoryWindowSeconds": 1800 }, + "steps": [ + { + "type": "getCredentials", + "response": "freshCredentials", + "lifetimeSeconds": 21600, + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false, "advisoryWindowSeconds": 1800 } + } + ] + }, + + { + "documentation": "Advisory window, non-recoverable failure: the SDK raises immediately. No refresh backoff is applied, but the error is cached for up to 5 seconds, so a recovering call succeeds once that cache expires.", + "given": { "cachedCredentials": "advisory" }, + "steps": [ + { + "type": "getCredentials", + "response": "nonRecoverableError", + "documentation": "Non-recoverable failure: the SDK raises and does not apply the refresh backoff.", + "expected": { "result": "nonRecoverableError", "sourceContacted": true, "rateLimited": false } + }, + { + "type": "advanceTime", + "seconds": 6 + }, + { + "type": "getCredentials", + "response": "freshCredentials", + "documentation": "The non-recoverable error cache (max 5 seconds) has expired, so this call contacts the source again and succeeds.", + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + }, + { + "documentation": "Mandatory window, non-recoverable failure: the SDK raises immediately. No refresh backoff is applied, but the error is cached for up to 5 seconds, so a recovering call succeeds once that cache expires.", + "given": { "cachedCredentials": "mandatory" }, + "steps": [ + { + "type": "getCredentials", + "response": "nonRecoverableError", + "documentation": "Non-recoverable failure: the SDK raises and does not apply the refresh backoff.", + "expected": { "result": "nonRecoverableError", "sourceContacted": true, "rateLimited": false } + }, + { + "type": "advanceTime", + "seconds": 6 + }, + { + "type": "getCredentials", + "response": "freshCredentials", + "documentation": "The non-recoverable error cache (max 5 seconds) has expired, so this call contacts the source again and succeeds.", + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + }, + { + "documentation": "Non-recoverable error, then an immediate retry with no clock advance: the error is still cached, so the SDK re-raises it without contacting the source. This protects the credential source from an application that swallows the error and retries in a loop.", + "given": { "cachedCredentials": "advisory" }, + "steps": [ + { + "type": "getCredentials", + "response": "nonRecoverableError", + "documentation": "Non-recoverable failure: the SDK raises and caches the error for up to 5 seconds.", + "expected": { "result": "nonRecoverableError", "sourceContacted": true, "rateLimited": false } + }, + { + "type": "getCredentials", + "documentation": "Immediate retry with no clock advance. The cached error is still active, so the SDK re-raises it without contacting the source.", + "expected": { "result": "nonRecoverableError", "sourceContacted": false, "rateLimited": false } + } + ] + }, + + { + "documentation": "Invalidate with an access key ID matching the cached credentials routes the next getCredentials through the mandatory refresh path, and the refresh succeeds.", + "given": { "cachedCredentials": "valid", "accessKeyId": "AKID-1" }, + "steps": [ + { "type": "invalidate" }, + { + "type": "getCredentials", + "response": "freshCredentials", + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + }, + { + "documentation": "Invalidate with a matching access key ID routes the next getCredentials through the mandatory refresh path; the refresh fails and the SDK continues using the cached credentials.", + "given": { "cachedCredentials": "valid", "accessKeyId": "AKID-1" }, + "steps": [ + { "type": "invalidate" }, + { + "type": "getCredentials", + "response": "error", + "expected": { "result": "cachedCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + }, + { + "documentation": "Invalidate during an active backoff: the SDK does not contact the credential source. Once the refresh backoff has elapsed, the next getCredentials attempts a refresh.", + "given": { "cachedCredentials": "expired", "accessKeyId": "AKID-1", "refreshBackoffSeconds": 420 }, + "steps": [ + { + "type": "getCredentials", + "response": "error", + "documentation": "Refresh fails, so the SDK applies the refresh backoff.", + "expected": { "result": "cachedCredentials", "sourceContacted": true, "rateLimited": false } + }, + { + "type": "advanceTime", + "seconds": 60 + }, + { "type": "invalidate" }, + { + "type": "getCredentials", + "documentation": "60s elapsed and the refresh backoff has not yet elapsed, so even after invalidation the SDK does not contact the credential source.", + "expected": { "result": "cachedCredentials", "sourceContacted": false, "rateLimited": true } + }, + { + "type": "advanceTime", + "seconds": 425 + }, + { + "type": "getCredentials", + "response": "freshCredentials", + "documentation": "485s elapsed total and the refresh backoff has elapsed, so the SDK contacts the credential source and the refresh succeeds.", + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + }, + { + "documentation": "Invalidate is argument-less (no access-key-id matching, per design doc section 4.7): it routes the next getCredentials through the mandatory refresh path regardless of which credential the service rejected. If a concurrent refresh already replaced the credentials, this costs at most one harmless extra fetch.", + "given": { "cachedCredentials": "valid", "accessKeyId": "AKID-2" }, + "steps": [ + { "type": "invalidate" }, + { + "type": "getCredentials", + "response": "freshCredentials", + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + }, + + { + "documentation": "After a failed refresh, the SDK does not contact the credential source again until the refresh backoff has elapsed.", + "given": { "cachedCredentials": "expired", "refreshBackoffSeconds": 420 }, + "steps": [ + { + "type": "getCredentials", + "response": "error", + "documentation": "Refresh fails, so the SDK applies the refresh backoff.", + "expected": { "result": "cachedCredentials", "sourceContacted": true, "rateLimited": false } + }, + { + "type": "advanceTime", + "seconds": 300 + }, + { + "type": "getCredentials", + "documentation": "300s elapsed and the refresh backoff has not yet elapsed, so the SDK does not contact the credential source.", + "expected": { "result": "cachedCredentials", "sourceContacted": false, "rateLimited": true } + }, + { + "type": "advanceTime", + "seconds": 425 + }, + { + "type": "getCredentials", + "response": "freshCredentials", + "documentation": "725s elapsed total and the refresh backoff has elapsed, so the SDK contacts the credential source and the refresh succeeds.", + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + }, + { + "documentation": "No cached credentials and the initial fetch fails with a non-recoverable error: the SDK raises the error directly rather than a generic NoCredentialsError. No refresh backoff is applied, but the error is cached for up to 5 seconds, so a recovering call succeeds once that cache expires.", + "given": { "cachedCredentials": "none" }, + "steps": [ + { + "type": "getCredentials", + "response": "nonRecoverableError", + "documentation": "Non-recoverable failure: the SDK raises and does not apply the refresh backoff.", + "expected": { "result": "nonRecoverableError", "sourceContacted": true, "rateLimited": false } + }, + { + "type": "advanceTime", + "seconds": 6 + }, + { + "type": "getCredentials", + "response": "freshCredentials", + "documentation": "The non-recoverable error cache (max 5 seconds) has expired, so this call contacts the source again and succeeds.", + "expected": { "result": "newCredentials", "sourceContacted": true, "rateLimited": false } + } + ] + } +] diff --git a/tests/aws-cpp-sdk-core-tests/utils/CredentialsRefreshTest.cpp b/tests/aws-cpp-sdk-core-tests/utils/CredentialsRefreshTest.cpp new file mode 100644 index 000000000000..d5871d7fec65 --- /dev/null +++ b/tests/aws-cpp-sdk-core-tests/utils/CredentialsRefreshTest.cpp @@ -0,0 +1,230 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0. + */ + +// Runs the vendored credentials-refresh test cases (resources/credentials-refresh-tests.json) through the +// refresh state machine, plus a base-class test driving the same lifecycle through AWSCredentialsProvider. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Aws::Internal; +using Aws::Utils::DateTime; +using Aws::Utils::Json::JsonValue; +using Aws::Utils::Json::JsonView; +using Aws::Auth::AWSCredentials; +using Aws::Auth::AWSCredentialsProvider; +using Aws::Auth::RefreshResult; + +namespace +{ + struct Creds { Aws::String akid; }; + using Result = RefreshResult; + using Cache = CredentialsRefreshState; + + // Fixed jitter so the pinned durations are exact: backoff = 420 s, non-recoverable cache = 2.6 s. + const double JITTER = 0.4; + + Aws::String ReadFileContents(const char* path) + { + std::ifstream stream(path); + std::stringstream buffer; + buffer << stream.rdbuf(); + return Aws::String(buffer.str().c_str()); + } +} + +class CredentialsRefreshTest : public Aws::Testing::AwsCppSdkGTestSuite {}; + +TEST_F(CredentialsRefreshTest, RunsSepTestCases) +{ + const Aws::String contents = ReadFileContents(CREDENTIALS_REFRESH_TEST_CASES_PATH); + JsonValue document(contents); + ASSERT_TRUE(document.WasParseSuccessful()) << "failed to parse " << CREDENTIALS_REFRESH_TEST_CASES_PATH; + const auto cases = document.View().AsArray(); + ASSERT_GT(cases.GetLength(), 0u); + + for (size_t i = 0; i < cases.GetLength(); ++i) + { + const JsonView testCase = cases.GetItem(i); + SCOPED_TRACE(testCase.GetString("documentation").c_str()); + const JsonView given = testCase.GetObject("given"); + // No configurable refresh window in C++; log + continue (a per-case GTEST_SKIP would abort the loop). + if (given.KeyExists("configuredAdvisoryWindowSeconds")) + { + GTEST_LOG_(INFO) << "Skipping corpus case (no configurable refresh window): " << testCase.GetString("documentation"); + continue; + } + const Aws::String initialState = given.GetString("cachedCredentials"); + const Aws::String seedAkid = given.KeyExists("accessKeyId") ? given.GetString("accessKeyId") + : Aws::String("AKID-seed"); + + auto now = std::make_shared(DateTime::Now()); + auto next = std::make_shared(); + Cache cache([next]() { return *next; }, + [now]() { return *now; }, + []() { return JITTER; }); + + // Seed the initial cache state with a 60-minute credential (15-minute advisory window), then + // advance the clock so the credentials land in the requested window. + if (initialState != "none") + { + *next = Result::Fresh(Creds{seedAkid}, *now + std::chrono::minutes(60)); + cache.GetCredentials(); + if (initialState == "advisory") { *now = *now + std::chrono::minutes(50); } + else if (initialState == "mandatory") { *now = *now + std::chrono::minutes(59) + std::chrono::seconds(30); } + else if (initialState == "expired") { *now = *now + std::chrono::minutes(61); } + // "valid": leave the clock at the fetch time. + } + + const auto steps = testCase.GetArray("steps"); + for (size_t s = 0; s < steps.GetLength(); ++s) + { + const JsonView step = steps.GetItem(s); + const Aws::String type = step.GetString("type"); + + if (type == "advanceTime") + { + *now = *now + std::chrono::seconds(step.GetInteger("seconds")); + continue; + } + if (type == "invalidate") + { + cache.Invalidate(); // argument-less + continue; + } + + // getCredentials: program what the source returns on this call (if it is contacted). + if (step.KeyExists("response")) + { + const Aws::String response = step.GetString("response"); + Result programmed; + if (response == "freshCredentials") + { + const int lifetimeSeconds = step.KeyExists("lifetimeSeconds") ? step.GetInteger("lifetimeSeconds") : 3600; + programmed = Result::Fresh(Creds{"FRESH"}, *now + std::chrono::seconds(lifetimeSeconds)); + } + else if (response == "staleCredentials") + { + programmed = Result::Fresh(Creds{"STALE"}, *now + std::chrono::seconds(-1)); // Expiration at/before now + } + else if (response == "error") + { + programmed = Result::Recoverable("recoverable"); + } + else if (response == "nonRecoverableError") + { + programmed = Result::NonRecoverable("non-recoverable"); + } + *next = programmed; + } + + Cache::ResolveObservation observation; + const auto outcome = cache.GetCredentials(&observation); + + const JsonView expected = step.GetObject("expected"); + Aws::String actualResult; + if (outcome.IsSuccess()) + { + actualResult = observation.returnedNewCredentials ? "newCredentials" : "cachedCredentials"; + } + else + { + actualResult = observation.nonRecoverable ? "nonRecoverableError" : "noCredentialsError"; + } + + EXPECT_EQ(expected.GetString("result"), actualResult) << "step " << s; + EXPECT_EQ(expected.GetBool("sourceContacted"), observation.sourceContacted) << "step " << s; + if (expected.KeyExists("rateLimited")) + { + EXPECT_EQ(expected.GetBool("rateLimited"), observation.rateLimited) << "step " << s; + } + if (expected.KeyExists("advisoryWindowSeconds")) + { + EXPECT_EQ(static_cast(expected.GetInteger("advisoryWindowSeconds")), + static_cast(observation.advisoryWindow.count() / 1000)) << "step " << s; + } + } + } +} + +namespace +{ + // Scripted source the decorator wraps; each fetch returns whatever was programmed. + class ScriptedSource : public Aws::Auth::CredentialsSource + { + public: + void SetNext(RefreshResult next) { m_next = std::move(next); } + int FetchCount() const { return m_fetchCount; } + + RefreshResult FetchCredentials() override + { + ++m_fetchCount; + return m_next; + } + + private: + RefreshResult m_next{RefreshResult::Recoverable("unscripted")}; + int m_fetchCount{0}; + }; + + // Decorator with an injected clock so the pinned window durations are exact. + class ClockedRefreshProvider : public Aws::Auth::CredentialsRefreshProvider + { + public: + ClockedRefreshProvider(std::shared_ptr source, std::shared_ptr clock) + : CredentialsRefreshProvider(std::move(source)), m_now(std::move(clock)) + { + } + + protected: + DateTime CurrentTime() const override { return *m_now; } + + private: + std::shared_ptr m_now; + }; + + RefreshResult FreshCreds(const Aws::String& akid, const DateTime& expiration) + { + return RefreshResult::Fresh(AWSCredentials(akid, "secret"), expiration); + } +} + +TEST_F(CredentialsRefreshTest, RunsLifecycleAndInvalidate) +{ + auto now = std::make_shared(DateTime::Now()); + auto source = Aws::MakeShared("CredentialsRefreshTest"); + ClockedRefreshProvider provider(source, now); + + // Initial fetch: 60-minute credentials. + source->SetNext(FreshCreds("AKID-1", *now + std::chrono::minutes(60))); + EXPECT_EQ("AKID-1", provider.GetAWSCredentials().GetAWSAccessKeyId()); + EXPECT_EQ(1, source->FetchCount()); + + // Still valid: no re-fetch. + EXPECT_EQ("AKID-1", provider.GetAWSCredentials().GetAWSAccessKeyId()); + EXPECT_EQ(1, source->FetchCount()); + + // Advance into the advisory window (50 min in; 15-min window for a 60-min lifetime): refresh succeeds. + *now = *now + std::chrono::minutes(50); + source->SetNext(FreshCreds("AKID-2", *now + std::chrono::minutes(60))); + EXPECT_EQ("AKID-2", provider.GetAWSCredentials().GetAWSAccessKeyId()); + EXPECT_EQ(2, source->FetchCount()); + + // Argument-less Invalidate routes the next call through the mandatory path, forcing a re-fetch. + source->SetNext(FreshCreds("AKID-3", *now + std::chrono::minutes(60))); + provider.Invalidate(); + EXPECT_EQ("AKID-3", provider.GetAWSCredentials().GetAWSAccessKeyId()); + EXPECT_EQ(3, source->FetchCount()); +} + +// Serve-last-good and the strict caching-only variant are exercised by RunsSepTestCases.