This is an automated email from the ASF dual-hosted git repository.
wgtmac pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new 9c478f75 feat(auth): implement AuthSessionCache for OAuth2 child
sessions (#985)
9c478f75 is described below
commit 9c478f7571e89ef6388c4fa776d7a4e5f448cbac
Author: lishuxu <[email protected]>
AuthorDate: Wed Nov 4 16:43:28 2026 +0800
feat(auth): implement AuthSessionCache for OAuth2 child sessions (#985)
Cache contextual and table-scoped OAuth2 child sessions so that context
credentials do not trigger a token request on every catalog call (#962).
AuthSessionCache:
- Expire entries after auth.session-timeout-ms without access (default
1 hour), and when the token of an OAuth2 session expires.
- Load each key once under concurrent Get() calls, with the loader
running outside the lock. Failed loads are not cached.
- Hand every removed session to the removal listener exactly once. A
session loaded after Close() is removed immediately.
- Sweep expired entries periodically on the auth refresh executor, every
session timeout clamped to [1s, 60s]. Without an executor, entries are
reclaimed when accessed again or on Close().
OAuth2Manager:
- Cache contextual sessions by session_id and table sessions by token.
- Authenticate child token requests with the parent session's headers.
- Credential child sessions follow token-refresh-enabled and, with
refresh enabled, require the auth executor. Token and exchange child
sessions do not refresh until refresh via token exchange is supported.
- Token child sessions no longer carry the parent's credential.
OAuth2Session:
- Add StopRefreshing(), a non-blocking stop used on cache removal.
- Add ExpiresAt() and IsRefreshable(), and include the session headers
in OAuth2Info().
---
src/iceberg/catalog/rest/CMakeLists.txt | 1 +
src/iceberg/catalog/rest/auth/auth_manager.cc | 293 +++++++++--
src/iceberg/catalog/rest/auth/auth_properties.h | 8 +
src/iceberg/catalog/rest/auth/auth_session.h | 1 +
.../catalog/rest/auth/auth_session_cache.cc | 320 +++++++++++
.../rest/auth/auth_session_cache_internal.h | 161 ++++++
.../catalog/rest/auth/auth_session_internal.h | 49 +-
src/iceberg/catalog/session_context.h | 3 +
src/iceberg/test/CMakeLists.txt | 1 +
src/iceberg/test/auth_manager_test.cc | 319 +++++++++++
src/iceberg/test/auth_session_cache_test.cc | 582 +++++++++++++++++++++
src/iceberg/test/rest_catalog_integration_test.cc | 7 +
12 files changed, 1705 insertions(+), 40 deletions(-)
diff --git a/src/iceberg/catalog/rest/CMakeLists.txt
b/src/iceberg/catalog/rest/CMakeLists.txt
index a8146d46..65c03724 100644
--- a/src/iceberg/catalog/rest/CMakeLists.txt
+++ b/src/iceberg/catalog/rest/CMakeLists.txt
@@ -22,6 +22,7 @@ set(ICEBERG_REST_SOURCES
auth/auth_managers.cc
auth/auth_properties.cc
auth/auth_session.cc
+ auth/auth_session_cache.cc
auth/oauth2_util.cc
auth/sigv4_manager.cc
catalog_properties.cc
diff --git a/src/iceberg/catalog/rest/auth/auth_manager.cc
b/src/iceberg/catalog/rest/auth/auth_manager.cc
index df5d7304..0fad64ec 100644
--- a/src/iceberg/catalog/rest/auth/auth_manager.cc
+++ b/src/iceberg/catalog/rest/auth/auth_manager.cc
@@ -21,18 +21,23 @@
#include <array>
#include <chrono>
+#include <mutex>
#include <optional>
+#include <string>
#include <string_view>
#include <utility>
#include "iceberg/catalog/rest/auth/auth_manager_internal.h"
#include "iceberg/catalog/rest/auth/auth_properties.h"
#include "iceberg/catalog/rest/auth/auth_session.h"
+#include "iceberg/catalog/rest/auth/auth_session_cache_internal.h"
#include "iceberg/catalog/rest/auth/auth_session_internal.h"
#include "iceberg/catalog/rest/auth/oauth2_util.h"
#include "iceberg/catalog/session_context.h"
+#include "iceberg/logging/log_macros.h"
#include "iceberg/util/base64.h"
#include "iceberg/util/macros.h"
+#include "iceberg/util/string_util.h"
namespace iceberg::rest::auth {
@@ -57,6 +62,127 @@ std::optional<std::pair<std::string, std::string>>
FindPreferredTypedToken(
return std::nullopt;
}
+/// \brief Credential used to create an OAuth2 child session.
+struct ChildCredential {
+ enum class Kind { kToken, kCredential, kTokenExchange };
+
+ Kind kind;
+ std::string value;
+ /// Subject token type; only set for kTokenExchange.
+ std::string token_type;
+};
+
+/// \brief Select the credential for a child session.
+///
+/// The order matches Java: a bearer token, then a client credential (only when
+/// `allow_credential` is true), then a typed token in kTokenPreferenceOrder.
+std::optional<ChildCredential> SelectChildCredential(
+ const std::unordered_map<std::string, std::string>& credentials,
+ bool allow_credential) {
+ if (auto token_it = credentials.find(AuthProperties::kToken.key());
+ token_it != credentials.end()) {
+ return ChildCredential{.kind = ChildCredential::Kind::kToken,
+ .value = token_it->second};
+ }
+ if (allow_credential) {
+ if (auto credential_it =
credentials.find(AuthProperties::kCredential.key());
+ credential_it != credentials.end()) {
+ return ChildCredential{.kind = ChildCredential::Kind::kCredential,
+ .value = credential_it->second};
+ }
+ }
+ if (auto typed_token = FindPreferredTypedToken(credentials);
typed_token.has_value()) {
+ return ChildCredential{.kind = ChildCredential::Kind::kTokenExchange,
+ .value = std::move(typed_token->second),
+ .token_type = std::move(typed_token->first)};
+ }
+ return std::nullopt;
+}
+
+/// \brief Actor token sent with a token exchange.
+struct ActorToken {
+ std::string token;
+ std::string token_type;
+};
+
+/// \brief The parent session's token, sent as the actor of a token exchange,
or
+/// std::nullopt if the parent has no token.
+std::optional<ActorToken> ExchangeActor(const OAuth2SessionInfo& parent_info) {
+ if (parent_info.token.empty()) {
+ return std::nullopt;
+ }
+ return ActorToken{.token = parent_info.token,
+ .token_type = parent_info.issued_token_type};
+}
+
+void AppendCacheKeyPart(std::string& key, std::string_view part) {
+ key += std::to_string(part.size());
+ key += ':';
+ key += part;
+}
+
+/// \brief Build the cache key of a table session.
+///
+/// The key covers every input that determines how the session authenticates,
so
+/// two table sessions are shared only when they would send the same
credentials.
+/// With P(s) = "<byte length of s>:<s>", the formats are:
+///
+/// bearer token: "tbl:" + P("token") + P(token)
+/// token exchange: "tbl:" + P("exchange") + P(subject type) + P(subject)
+/// + P(actor type) + P(actor)
+///
+/// A bearer token is sent as-is, so its key does not depend on the parent. An
+/// exchanged token also depends on the actor, the parent's token; the actor
parts
+/// are empty when the parent has no token, matching the exchange request. The
+/// length prefix keeps the key unambiguous although token types contain ':'.
+///
+/// For example, bearer token "abc" gives "tbl:5:token3:abc", and JWT "eyJ.a.b"
+/// exchanged under the parent access token "tok" gives (on one line):
+///
+/// tbl:8:exchange36:urn:ietf:params:oauth:token-type:jwt7:eyJ.a.b
+/// 45:urn:ietf:params:oauth:token-type:access_token3:tok
+///
+/// An empty token gives an empty key, which disables caching.
+std::string TableCacheKey(const ChildCredential& credential,
+ const OAuth2SessionInfo& parent_info) {
+ if (credential.value.empty()) {
+ return {};
+ }
+ std::string key = "tbl:";
+ switch (credential.kind) {
+ case ChildCredential::Kind::kToken:
+ AppendCacheKeyPart(key, "token");
+ AppendCacheKeyPart(key, credential.value);
+ return key;
+ case ChildCredential::Kind::kTokenExchange: {
+ auto actor = ExchangeActor(parent_info);
+ AppendCacheKeyPart(key, "exchange");
+ AppendCacheKeyPart(key, credential.token_type);
+ AppendCacheKeyPart(key, credential.value);
+ AppendCacheKeyPart(key, actor.has_value() ? actor->token_type : "");
+ AppendCacheKeyPart(key, actor.has_value() ? actor->token : "");
+ return key;
+ }
+ case ChildCredential::Kind::kCredential:
+ // Table sessions never use a client credential.
+ return {};
+ }
+ std::unreachable();
+}
+
+/// \brief Parse auth.session-timeout-ms, returning an error for a malformed or
+/// negative value rather than throwing as AuthProperties::Get() would.
+Result<std::chrono::milliseconds> ParseSessionTimeout(const AuthProperties&
config) {
+ const auto& entry = AuthProperties::kSessionTimeoutMs;
+ int64_t timeout_ms = entry.value();
+ if (auto it = config.configs().find(entry.key()); it !=
config.configs().end()) {
+ ICEBERG_ASSIGN_OR_RAISE(timeout_ms,
StringUtils::ParseNumber<int64_t>(it->second));
+ }
+ ICEBERG_PRECHECK(timeout_ms >= 0, "Invalid value for {}: {} (must not be
negative)",
+ entry.key(), timeout_ms);
+ return std::chrono::milliseconds(timeout_ms);
+}
+
std::unordered_map<std::string, std::string> FilterTableSessionProperties(
const std::unordered_map<std::string, std::string>& properties) {
std::unordered_map<std::string, std::string> filtered;
@@ -180,6 +306,30 @@ class OAuth2Manager : public AuthManager {
ICEBERG_PRECHECK(shared_client != nullptr,
"OAuth2 catalog session HTTP client must not be null");
refresh_client_ = std::move(shared_client);
+
+ // Initialize catalog-level configuration
+ keep_refreshed_ = config.keep_refreshed();
+ ICEBERG_ASSIGN_OR_RAISE(session_timeout_, ParseSessionTimeout(config));
+
+ // Create session cache
+ ICEBERG_ASSIGN_OR_RAISE(
+ session_cache_,
+ internal::AuthSessionCache::Make(
+ session_timeout_, [](std::shared_ptr<AuthSession> session) {
+ if (auto oauth2 =
+
std::dynamic_pointer_cast<internal::OAuth2Session>(session)) {
+ oauth2->StopRefreshing();
+ }
+ }));
+
+ // Reclaim idle sessions in the background on the refresh executor.
Without an
+ // executor, the cache sweeps from Get() instead.
+ if (refresh_executor_ != nullptr) {
+ ICEBERG_RETURN_UNEXPECTED(session_cache_->StartPeriodicSweep(
+ *refresh_executor_,
+ internal::AuthSessionCache::DefaultSweepInterval(session_timeout_)));
+ }
+
// Reuse the token response and start time from the init phase.
if (auth_response_.has_value()) {
return MakeSession(*auth_response_, config, config.keep_refreshed(),
start_time_);
@@ -212,21 +362,35 @@ class OAuth2Manager : public AuthManager {
Result<std::shared_ptr<AuthSession>> ContextualSession(
const SessionContext& context, std::shared_ptr<AuthSession> parent)
override {
- // TODO(lishuxu): Add child-session caching and refresh, matching Java
- // AuthSessionCache.
- return MaybeCreateChildSession(context.credentials,
/*allow_credential=*/true,
- std::move(parent));
+ auto credential =
+ SelectChildCredential(context.credentials, /*allow_credential=*/true);
+ if (!credential.has_value()) {
+ return parent;
+ }
+ ICEBERG_ASSIGN_OR_RAISE(auto parent_info, ParentInfo(*parent));
+ // Contextual sessions are cached by session ID, whatever credential they
use.
+ std::string cache_key = context.session_id.empty() ? "" : "ctx:" +
context.session_id;
+ return GetOrCreateChildSession(*credential, parent_info, cache_key);
}
Result<std::shared_ptr<AuthSession>> TableSession(
[[maybe_unused]] const TableIdentifier& table,
const std::unordered_map<std::string, std::string>& properties,
std::shared_ptr<AuthSession> parent) override {
- return MaybeCreateChildSession(FilterTableSessionProperties(properties),
- /*allow_credential=*/false,
std::move(parent));
+ auto credential =
SelectChildCredential(FilterTableSessionProperties(properties),
+ /*allow_credential=*/false);
+ if (!credential.has_value()) {
+ return parent;
+ }
+ ICEBERG_ASSIGN_OR_RAISE(auto parent_info, ParentInfo(*parent));
+ return GetOrCreateChildSession(*credential, parent_info,
+ TableCacheKey(*credential, parent_info));
}
Status Close() override {
+ if (session_cache_) {
+ session_cache_->Close();
+ }
refresh_client_.reset();
return {};
}
@@ -263,54 +427,99 @@ class OAuth2Manager : public AuthManager {
token_request_started_at,
refresh_executor_);
}
- Result<std::shared_ptr<AuthSession>> MaybeCreateChildSession(
- const std::unordered_map<std::string, std::string>& credentials,
- bool allow_credential, std::shared_ptr<AuthSession> parent) {
- auto token_it = credentials.find(AuthProperties::kToken.key());
- auto credential_it = credentials.find(AuthProperties::kCredential.key());
- auto typed_token = FindPreferredTypedToken(credentials);
- if (token_it == credentials.end() &&
- (!allow_credential || credential_it == credentials.end()) &&
- !typed_token.has_value()) {
- return parent;
- }
-
+ Result<OAuth2SessionInfo> ParentInfo(const AuthSession& parent) const {
ICEBERG_PRECHECK(refresh_client_ != nullptr,
"OAuth2 catalog session must be initialized before child
sessions");
- auto parent_info = parent->OAuth2Info();
+ auto parent_info = parent.OAuth2Info();
ICEBERG_PRECHECK(parent_info.has_value(),
"OAuth2 child session requires OAuth2 parent metadata");
+ return std::move(*parent_info);
+ }
- if (token_it != credentials.end()) {
- ICEBERG_ASSIGN_OR_RAISE(auto config,
- ChildConfig(*parent_info,
parent_info->credential));
- return MakeSession(AccessTokenResponse(token_it->second), config,
- /*keep_refreshed=*/false);
+ Result<std::shared_ptr<AuthSession>> GetOrCreateChildSession(
+ const ChildCredential& credential, const OAuth2SessionInfo& parent_info,
+ const std::string& cache_key) {
+ // Use cache if we have a valid cache_key
+ if (!cache_key.empty() && session_cache_) {
+ return session_cache_->Get(
+ cache_key, [&]() -> Result<std::shared_ptr<AuthSession>> {
+ return CreateChildSessionUncached(credential, parent_info);
+ });
}
- if (allow_credential && credential_it != credentials.end()) {
- ICEBERG_ASSIGN_OR_RAISE(auto config,
- ChildConfig(*parent_info,
credential_it->second));
- ICEBERG_ASSIGN_OR_RAISE(auto response,
- OAuth2Util::FetchToken(*refresh_client_,
*parent, config));
- return MakeSession(response, config, /*keep_refreshed=*/false);
+ // No cache, create session directly
+ return CreateChildSessionUncached(credential, parent_info);
+ }
+
+ Result<std::shared_ptr<AuthSession>> CreateChildSessionUncached(
+ const ChildCredential& credential, const OAuth2SessionInfo& parent_info)
{
+ switch (credential.kind) {
+ case ChildCredential::Kind::kToken:
+ return CreateTokenChildSession(credential.value, parent_info);
+ case ChildCredential::Kind::kCredential:
+ return CreateCredentialChildSession(credential.value, parent_info);
+ case ChildCredential::Kind::kTokenExchange:
+ return CreateTokenExchangeChildSession(credential.value,
credential.token_type,
+ parent_info);
}
+ std::unreachable();
+ }
+ Result<std::shared_ptr<AuthSession>> CreateTokenChildSession(
+ const std::string& token, const OAuth2SessionInfo& parent_info) {
+ // Token child session: don't include parent credential in config.
+ // Token-based sessions are not refreshed; when token exchange is enabled,
+ // refresh will use the token itself rather than the parent credential.
+ auto properties = parent_info.optional_oauth_params;
+ properties[AuthProperties::kScope.key()] = parent_info.scope;
+ properties[AuthProperties::kOAuth2ServerUri.key()] =
parent_info.oauth2_server_uri;
+ ICEBERG_ASSIGN_OR_RAISE(auto config,
AuthProperties::FromProperties(properties));
+ return MakeSession(AccessTokenResponse(token), config,
/*keep_refreshed=*/false);
+ }
+
+ Result<std::shared_ptr<AuthSession>> CreateCredentialChildSession(
+ const std::string& credential, const OAuth2SessionInfo& parent_info) {
+ ICEBERG_ASSIGN_OR_RAISE(auto config, ChildConfig(parent_info, credential));
+ // Use parent session headers for FetchToken authentication
+ auto temp_parent = AuthSession::MakeDefault(parent_info.headers);
+ auto token_request_started_at = std::chrono::steady_clock::now();
+ ICEBERG_ASSIGN_OR_RAISE(
+ auto response, OAuth2Util::FetchToken(*refresh_client_, *temp_parent,
config));
+ // Credential child sessions follow the catalog's keep_refreshed, but
refreshing
+ // needs an executor. Without one, the child keeps its initial token.
+ if (keep_refreshed_ && refresh_executor_ == nullptr) {
+ std::call_once(no_refresh_executor_warning_, [] {
+ ICEBERG_LOG_WARN(
+ "OAuth2 credential child sessions will not refresh their tokens
because "
+ "no auth executor was provided");
+ });
+ }
+ return MakeSession(response, config, keep_refreshed_ && refresh_executor_
!= nullptr,
+ token_request_started_at);
+ }
+
+ Result<std::shared_ptr<AuthSession>> CreateTokenExchangeChildSession(
+ const std::string& subject_token, const std::string& subject_token_type,
+ const OAuth2SessionInfo& parent_info) {
std::optional<std::string> actor_token;
std::optional<std::string> actor_token_type;
- if (!parent_info->token.empty()) {
- actor_token = parent_info->token;
- actor_token_type = parent_info->issued_token_type;
+ if (auto actor = ExchangeActor(parent_info); actor.has_value()) {
+ actor_token = std::move(actor->token);
+ actor_token_type = std::move(actor->token_type);
}
+ // Use parent session headers for ExchangeToken authentication
+ auto temp_parent = AuthSession::MakeDefault(parent_info.headers);
+ auto token_request_started_at = std::chrono::steady_clock::now();
ICEBERG_ASSIGN_OR_RAISE(
auto response,
- OAuth2Util::ExchangeToken(*refresh_client_, *parent, {},
typed_token->second,
- typed_token->first, actor_token,
actor_token_type,
- parent_info->scope,
parent_info->oauth2_server_uri,
- parent_info->optional_oauth_params));
+ OAuth2Util::ExchangeToken(*refresh_client_, *temp_parent, {},
subject_token,
+ subject_token_type, actor_token,
actor_token_type,
+ parent_info.scope,
parent_info.oauth2_server_uri,
+ parent_info.optional_oauth_params));
ICEBERG_ASSIGN_OR_RAISE(auto config,
- ChildConfig(*parent_info,
parent_info->credential));
- return MakeSession(response, config, /*keep_refreshed=*/false);
+ ChildConfig(parent_info, parent_info.credential));
+ return MakeSession(response, config, /*keep_refreshed=*/false,
+ token_request_started_at);
}
/// Token response and start time captured by InitSession.
@@ -318,6 +527,12 @@ class OAuth2Manager : public AuthManager {
std::optional<std::chrono::steady_clock::time_point> start_time_;
std::shared_ptr<HttpClient> refresh_client_;
Executor* refresh_executor_ = nullptr;
+
+ // Catalog-level configuration initialized in CatalogSession
+ bool keep_refreshed_ = true;
+ std::chrono::milliseconds session_timeout_{3'600'000};
+ std::shared_ptr<internal::AuthSessionCache> session_cache_;
+ std::once_flag no_refresh_executor_warning_;
};
Result<std::unique_ptr<AuthManager>> MakeOAuth2Manager(
diff --git a/src/iceberg/catalog/rest/auth/auth_properties.h
b/src/iceberg/catalog/rest/auth/auth_properties.h
index 8784194c..0cf9be5e 100644
--- a/src/iceberg/catalog/rest/auth/auth_properties.h
+++ b/src/iceberg/catalog/rest/auth/auth_properties.h
@@ -78,10 +78,18 @@ class ICEBERG_REST_EXPORT AuthProperties : public
ConfigBase<AuthProperties> {
inline static Entry<std::string> kOAuth2ServerUri{"oauth2-server-uri",
"v1/oauth/tokens"};
inline static Entry<bool> kKeepRefreshed{"token-refresh-enabled", true};
+
+ /// Whether to use token exchange for refreshing OAuth2 sessions.
+ /// NOTE: Not yet implemented; the value is ignored.
inline static Entry<bool> kExchangeEnabled{"token-exchange-enabled", true};
+
inline static Entry<std::string> kAudience{"audience", ""};
inline static Entry<std::string> kResource{"resource", ""};
+ /// Session cache timeout in milliseconds. Sessions will be eligible for
eviction
+ /// after this duration of inactivity. Default is 1 hour (3,600,000 ms).
+ inline static Entry<int64_t> kSessionTimeoutMs{"auth.session-timeout-ms",
3'600'000};
+
// ---- OAuth2 token type constants ----
inline static const std::string kAccessTokenType =
diff --git a/src/iceberg/catalog/rest/auth/auth_session.h
b/src/iceberg/catalog/rest/auth/auth_session.h
index 7e330706..393418e2 100644
--- a/src/iceberg/catalog/rest/auth/auth_session.h
+++ b/src/iceberg/catalog/rest/auth/auth_session.h
@@ -43,6 +43,7 @@ struct ICEBERG_REST_EXPORT OAuth2SessionInfo {
std::string scope;
std::string oauth2_server_uri;
std::unordered_map<std::string, std::string> optional_oauth_params;
+ std::unordered_map<std::string, std::string> headers;
};
/// \brief An authentication session that can authenticate outgoing HTTP
requests.
diff --git a/src/iceberg/catalog/rest/auth/auth_session_cache.cc
b/src/iceberg/catalog/rest/auth/auth_session_cache.cc
new file mode 100644
index 00000000..aa62a8ac
--- /dev/null
+++ b/src/iceberg/catalog/rest/auth/auth_session_cache.cc
@@ -0,0 +1,320 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include <algorithm>
+#include <exception>
+#include <utility>
+
+#include "iceberg/catalog/rest/auth/auth_session_cache_internal.h"
+#include "iceberg/catalog/rest/auth/auth_session_internal.h"
+#include "iceberg/logging/log_macros.h"
+#include "iceberg/util/macros.h"
+
+namespace iceberg::rest::auth::internal {
+
+namespace {
+
+// Bounds of DefaultSweepInterval(): sweeping at least once a minute reclaims
idle
+// sessions promptly, and at most once a second keeps a zero timeout from
sweeping
+// continuously.
+constexpr auto kMinSweepInterval = std::chrono::seconds(1);
+constexpr auto kMaxSweepInterval = std::chrono::minutes(1);
+
+// The loader may call user-provided code such as an HttpClient, which can
throw.
+// Turning exceptions into errors lets Get() always clean up the loading slot.
+Result<std::shared_ptr<AuthSession>> RunLoader(const AuthSessionCache::Loader&
loader) {
+ try {
+ return loader();
+ } catch (const std::exception& e) {
+ return UnknownError("Auth session loader threw: {}", e.what());
+ } catch (...) {
+ return UnknownError("Auth session loader threw a non-standard exception");
+ }
+}
+
+} // namespace
+
+Result<std::shared_ptr<AuthSessionCache>> AuthSessionCache::Make(
+ std::chrono::milliseconds session_timeout, RemovalListener on_removal,
Clock clock) {
+ ICEBERG_PRECHECK(session_timeout >= std::chrono::milliseconds::zero(),
+ "Auth session timeout must not be negative: {} ms",
+ session_timeout.count());
+ ICEBERG_PRECHECK(clock != nullptr, "Auth session cache clock must not be
null");
+ return std::shared_ptr<AuthSessionCache>(
+ new AuthSessionCache(session_timeout, std::move(on_removal),
std::move(clock)));
+}
+
+std::chrono::milliseconds AuthSessionCache::DefaultSweepInterval(
+ std::chrono::milliseconds session_timeout) {
+ return std::clamp<std::chrono::milliseconds>(session_timeout,
kMinSweepInterval,
+ kMaxSweepInterval);
+}
+
+AuthSessionCache::AuthSessionCache(std::chrono::milliseconds session_timeout,
+ RemovalListener on_removal, Clock clock)
+ : session_timeout_(session_timeout),
+ on_removal_(std::move(on_removal)),
+ clock_(std::move(clock)) {}
+
+AuthSessionCache::~AuthSessionCache() { Close(); }
+
+Result<std::shared_ptr<AuthSession>> AuthSessionCache::Get(const std::string&
key,
+ const Loader&
loader) {
+ // Phase 1: Look up cache (under lock)
+ std::shared_ptr<Slot> slot;
+ std::vector<std::shared_ptr<AuthSession>> removed;
+ {
+ std::unique_lock lock(mutex_);
+ while (true) {
+ if (closed_) {
+ return Invalid("Auth session cache is closed");
+ }
+ auto it = slots_.find(key);
+ if (it == slots_.end()) {
+ break;
+ }
+ auto existing = it->second;
+ if (existing->loading) {
+ // Wait for another thread to finish loading. Loop again after waking
+ // because a failed load removes the slot.
+ load_finished_.wait(lock, [&] { return closed_ || !existing->loading;
});
+ continue;
+ }
+ auto now = clock_();
+ if (!IsExpired(*existing, now)) {
+ TouchLocked(it, now);
+ return existing->session;
+ }
+ EraseLocked(it, removed);
+ break;
+ }
+ if (!sweep_scheduler_.has_value()) {
+ // Without a periodic sweep, keys that are never accessed again would
stay
+ // until Close(). Reclaim idle entries before adding one, so the cache
grows
+ // with recently used keys rather than with every key ever used.
+ EvictIdleLocked(clock_(), removed);
+ }
+ // Create loading slot. Other threads calling Get(key) will wait.
+ slot = std::make_shared<Slot>();
+ slots_.emplace(key, slot);
+ }
+
+ // Phase 2: Call loader (lock released)
+ NotifyRemoved(removed);
+ auto result = RunLoader(loader);
+ if (result.has_value() && result.value() == nullptr) {
+ result = Invalid("Auth session loader returned a null session");
+ }
+
+ // Phase 3: Update cache (re-acquire lock)
+ std::shared_ptr<AuthSession> orphan;
+ {
+ std::lock_guard lock(mutex_);
+ slot->loading = false;
+ auto it = slots_.find(key);
+ bool current = it != slots_.end() && it->second == slot;
+
+ if (!result.has_value()) {
+ // Failed loads are not cached. Remove the slot so next Get() can retry;
a
+ // loading slot is not in access_order_ yet.
+ if (current) {
+ slots_.erase(it);
+ }
+ } else if (closed_ || !current) {
+ // Cache closed during load, or slot replaced. Mark as orphan.
+ orphan = result.value();
+ } else {
+ slot->session = result.value();
+ TouchLocked(it, clock_());
+ }
+ }
+ load_finished_.notify_all();
+
+ if (orphan != nullptr) {
+ if (on_removal_) {
+ on_removal_(orphan);
+ }
+ return Invalid("Auth session cache is closed");
+ }
+ return result;
+}
+
+void AuthSessionCache::Sweep() {
+ std::vector<std::shared_ptr<AuthSession>> removed;
+ {
+ std::lock_guard lock(mutex_);
+ EvictIdleLocked(clock_(), removed);
+ }
+ NotifyRemoved(removed);
+}
+
+void AuthSessionCache::TouchLocked(SlotMap::iterator it,
+ std::chrono::steady_clock::time_point now) {
+ // Every last_access is read from the clock under the lock and its entry
moved to
+ // the back, so access_order_ stays sorted by last access.
+ Slot& slot = *it->second;
+ slot.last_access = now;
+ if (slot.access_pos.has_value()) {
+ access_order_.splice(access_order_.end(), access_order_, *slot.access_pos);
+ } else {
+ slot.access_pos = access_order_.insert(access_order_.end(),
+ AccessEntry{.key = &it->first,
.slot = &slot});
+ }
+}
+
+void AuthSessionCache::EraseLocked(SlotMap::iterator it,
+ std::vector<std::shared_ptr<AuthSession>>&
removed) {
+ Slot& slot = *it->second;
+ if (slot.access_pos.has_value()) {
+ access_order_.erase(*slot.access_pos);
+ }
+ removed.push_back(std::move(slot.session));
+ slots_.erase(it);
+}
+
+void AuthSessionCache::EvictIdleLocked(
+ std::chrono::steady_clock::time_point now,
+ std::vector<std::shared_ptr<AuthSession>>& removed) {
+ // Every entry after the first one that is not idle was accessed more
recently.
+ while (!access_order_.empty()) {
+ const AccessEntry& oldest = access_order_.front();
+ if (now - oldest.slot->last_access < session_timeout_) {
+ break;
+ }
+ EraseLocked(slots_.find(*oldest.key), removed);
+ }
+}
+
+Status AuthSessionCache::StartPeriodicSweep(Executor& executor,
+ std::chrono::milliseconds
interval) {
+ ICEBERG_PRECHECK(interval > std::chrono::milliseconds::zero(),
+ "Auth session sweep interval must be positive: {} ms",
+ interval.count());
+ std::lock_guard lock(mutex_);
+ if (closed_ || sweep_scheduler_.has_value()) {
+ return {};
+ }
+ sweep_scheduler_.emplace(executor);
+ sweep_interval_ = interval;
+ ScheduleSweepLocked();
+ return {};
+}
+
+void AuthSessionCache::Close() {
+ std::vector<std::shared_ptr<AuthSession>> removed;
+ {
+ std::lock_guard lock(mutex_);
+ if (closed_) {
+ return;
+ }
+ closed_ = true;
+ for (auto& [key, slot] : slots_) {
+ // Sessions still loading are handed to the listener when their load
ends.
+ if (!slot->loading) {
+ removed.push_back(std::move(slot->session));
+ }
+ }
+ slots_.clear();
+ access_order_.clear();
+ }
+ load_finished_.notify_all();
+ // Shut down outside the lock: Shutdown() waits for an in-progress
submission,
+ // whose callback may take the lock. No sweep is scheduled once closed_ is
set.
+ if (sweep_scheduler_.has_value()) {
+ sweep_scheduler_->Shutdown();
+ }
+ NotifyRemoved(removed);
+}
+
+size_t AuthSessionCache::size() const {
+ std::lock_guard lock(mutex_);
+ return slots_.size();
+}
+
+bool AuthSessionCache::IsExpired(const Slot& slot,
+ std::chrono::steady_clock::time_point now)
const {
+ // Check 1: Idle timeout (expireAfterAccess semantics)
+ if (now - slot.last_access >= session_timeout_) {
+ return true;
+ }
+
+ // Check 2: Token expiration, for sessions that will not refresh
+ // This covers sessions without refresh and sessions whose refresh gave up
after
+ // repeated failures. Such a session is useless once its token expires, and
+ // evicting it is harmless since stopping its refresh is a no-op; the next
Get()
+ // loads a new one. A session that is still refreshing is left alone: its
refresh
+ // may be in flight, and evicting it would stop the refresh for callers that
+ // still hold it.
+ auto oauth2 =
std::dynamic_pointer_cast<internal::OAuth2Session>(slot.session);
+ if (oauth2 && !oauth2->IsRefreshable()) {
+ auto expires_at = oauth2->ExpiresAt();
+ return expires_at.has_value() && now >= *expires_at;
+ }
+
+ return false;
+}
+
+void AuthSessionCache::ScheduleSweepLocked() {
+ // Scheduling under the cache lock ensures Close() either cancels this task
or
+ // sees closed_ and keeps it from being scheduled.
+ std::weak_ptr<AuthSessionCache> weak_self = weak_from_this();
+ sweep_scheduler_->Schedule(
+ sweep_interval_, ExecutorTask([weak_self] {
+ if (auto self = weak_self.lock()) {
+ self->RunScheduledSweep();
+ }
+ }),
+ ::iceberg::internal::TaskSubmissionCallback([weak_self](Status status) {
+ if (status.has_value()) {
+ return;
+ }
+ // The sweep did not run; keep sweeping so idle sessions are still
reclaimed.
+ ICEBERG_LOG_WARN("Failed to submit auth session cache sweep: {}",
+ status.error().message);
+ if (auto self = weak_self.lock()) {
+ self->ScheduleNextSweep();
+ }
+ }));
+}
+
+void AuthSessionCache::ScheduleNextSweep() {
+ std::lock_guard lock(mutex_);
+ if (!closed_) {
+ ScheduleSweepLocked();
+ }
+}
+
+void AuthSessionCache::RunScheduledSweep() {
+ Sweep();
+ ScheduleNextSweep();
+}
+
+void AuthSessionCache::NotifyRemoved(
+ std::vector<std::shared_ptr<AuthSession>>& sessions) const {
+ if (!on_removal_) {
+ return;
+ }
+ for (auto& session : sessions) {
+ if (session != nullptr) {
+ on_removal_(session);
+ }
+ }
+}
+
+} // namespace iceberg::rest::auth::internal
diff --git a/src/iceberg/catalog/rest/auth/auth_session_cache_internal.h
b/src/iceberg/catalog/rest/auth/auth_session_cache_internal.h
new file mode 100644
index 00000000..aee88147
--- /dev/null
+++ b/src/iceberg/catalog/rest/auth/auth_session_cache_internal.h
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#pragma once
+
+#include <chrono>
+#include <condition_variable>
+#include <cstddef>
+#include <functional>
+#include <list>
+#include <memory>
+#include <mutex>
+#include <optional>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include "iceberg/catalog/rest/iceberg_rest_export.h"
+#include "iceberg/catalog/rest/type_fwd.h"
+#include "iceberg/result.h"
+#include "iceberg/util/executor.h"
+#include "iceberg/util/scheduled_executor_internal.h"
+
+/// \file iceberg/catalog/rest/auth/auth_session_cache_internal.h
+/// \brief Cache of child authentication sessions, keyed by caller identity.
+
+namespace iceberg::rest::auth::internal {
+
+/// \brief Caches child auth sessions and expires them after a period of
inactivity.
+///
+/// Entries expire after `session_timeout` without access. OAuth2 sessions that
+/// will not refresh also expire once their token does; such an entry is
replaced
+/// when its key is next accessed. Each session that leaves the cache is
handed to
+/// the removal listener exactly once.
+///
+/// Idle entries are reclaimed in access order, least recently used first, so
the
+/// cost is proportional to the number of entries removed. Sweep() does this,
and
+/// StartPeriodicSweep() runs it in the background; without a periodic sweep,
Get()
+/// does it before adding an entry.
+///
+/// Thread safety: all public methods are thread-safe. Loaders and the removal
+/// listener are always invoked without holding the cache lock.
+class ICEBERG_REST_EXPORT AuthSessionCache
+ : public std::enable_shared_from_this<AuthSessionCache> {
+ public:
+ using Clock = std::function<std::chrono::steady_clock::time_point()>;
+ using Loader = std::function<Result<std::shared_ptr<AuthSession>>()>;
+ /// Called once for every session that leaves the cache. Must not block or
throw.
+ using RemovalListener = std::function<void(const
std::shared_ptr<AuthSession>&)>;
+
+ /// \brief Create a cache.
+ ///
+ /// \param session_timeout Idle time after which an entry expires; must not
be
+ /// negative. Zero disables caching in effect.
+ /// \param on_removal Listener invoked for each session that leaves the
cache.
+ /// \param clock Time source; tests can inject a fake clock.
+ static Result<std::shared_ptr<AuthSessionCache>> Make(
+ std::chrono::milliseconds session_timeout, RemovalListener on_removal,
+ Clock clock = std::chrono::steady_clock::now);
+
+ ~AuthSessionCache();
+
+ AuthSessionCache(const AuthSessionCache&) = delete;
+ AuthSessionCache& operator=(const AuthSessionCache&) = delete;
+
+ /// \brief Suggested StartPeriodicSweep() interval for `session_timeout`: the
+ /// timeout clamped to [1s, 60s].
+ static std::chrono::milliseconds DefaultSweepInterval(
+ std::chrono::milliseconds session_timeout);
+
+ /// \brief Return the cached session for `key`, loading it on a miss.
+ ///
+ /// Concurrent calls for the same key run `loader` at most once; the others
wait
+ /// for its result. Failed loads are not cached; an exception thrown by
`loader`
+ /// is returned as an UnknownError. Returns an error once the cache
+ /// is closed; a session loaded after Close() is handed to the removal
listener.
+ Result<std::shared_ptr<AuthSession>> Get(const std::string& key, const
Loader& loader);
+
+ /// \brief Remove entries idle for at least the session timeout and notify
the
+ /// removal listener. Entries whose token expired are replaced on their next
+ /// access instead.
+ void Sweep();
+
+ /// \brief Run Sweep() on `executor` every `interval` until Close().
+ ///
+ /// `executor` is borrowed and must outlive this cache and its submitted
tasks.
+ /// `interval` must be positive. Calling this more than once, or after
Close(),
+ /// has no effect.
+ Status StartPeriodicSweep(Executor& executor, std::chrono::milliseconds
interval);
+
+ /// \brief Stop periodic sweeping and remove every entry. Idempotent.
+ void Close();
+
+ /// \brief Number of entries, including loads in progress.
+ size_t size() const;
+
+ private:
+ struct Slot;
+
+ /// One element per loaded slot. `key` points at the key stored in slots_,
which
+ /// stays valid until that element is erased, even when the map rehashes.
+ struct AccessEntry {
+ const std::string* key;
+ Slot* slot;
+ };
+ using AccessOrder = std::list<AccessEntry>;
+
+ struct Slot {
+ bool loading = true;
+ std::shared_ptr<AuthSession> session;
+ std::chrono::steady_clock::time_point last_access;
+ /// Position in access_order_; set once the load succeeds.
+ std::optional<AccessOrder::iterator> access_pos;
+ };
+ using SlotMap = std::unordered_map<std::string, std::shared_ptr<Slot>>;
+
+ AuthSessionCache(std::chrono::milliseconds session_timeout, RemovalListener
on_removal,
+ Clock clock);
+
+ bool IsExpired(const Slot& slot, std::chrono::steady_clock::time_point now)
const;
+ void TouchLocked(SlotMap::iterator it, std::chrono::steady_clock::time_point
now);
+ void EraseLocked(SlotMap::iterator it,
+ std::vector<std::shared_ptr<AuthSession>>& removed);
+ void EvictIdleLocked(std::chrono::steady_clock::time_point now,
+ std::vector<std::shared_ptr<AuthSession>>& removed);
+ void ScheduleSweepLocked();
+ void ScheduleNextSweep();
+ void RunScheduledSweep();
+ void NotifyRemoved(std::vector<std::shared_ptr<AuthSession>>& sessions)
const;
+
+ const std::chrono::milliseconds session_timeout_;
+ const RemovalListener on_removal_;
+ const Clock clock_;
+
+ mutable std::mutex mutex_;
+ std::condition_variable load_finished_;
+ SlotMap slots_;
+ /// Loaded slots ordered by last access, least recent first.
+ AccessOrder access_order_;
+ bool closed_ = false;
+ std::optional<::iceberg::internal::ScheduledExecutor> sweep_scheduler_;
+ std::chrono::milliseconds sweep_interval_{0};
+};
+
+} // namespace iceberg::rest::auth::internal
diff --git a/src/iceberg/catalog/rest/auth/auth_session_internal.h
b/src/iceberg/catalog/rest/auth/auth_session_internal.h
index fa2f7ede..a4454aaf 100644
--- a/src/iceberg/catalog/rest/auth/auth_session_internal.h
+++ b/src/iceberg/catalog/rest/auth/auth_session_internal.h
@@ -102,11 +102,46 @@ class OAuth2Session final : public AuthSession,
.scope = config_.scope,
.oauth2_server_uri = config_.token_endpoint,
.optional_oauth_params = config_.optional_oauth_params,
+ .headers = headers_,
};
}
Status Close() override { return CloseImpl(); }
+ /// \brief Stop scheduling token refreshes without waiting.
+ ///
+ /// Unlike Close(), this does not wait for an in-flight refresh to finish,
so it
+ /// is safe to call from any thread, including refresh executor workers. The
+ /// session keeps its current token and remains usable; an in-flight refresh
may
+ /// still update the token but will not schedule further refreshes.
+ void StopRefreshing() {
+ {
+ std::lock_guard lock(refresh_mutex_);
+ closed_ = true;
+ }
+ if (scheduler_) {
+ scheduler_->Shutdown();
+ }
+ }
+
+ /// \brief Expiration time of the current token, or std::nullopt if unknown.
+ std::optional<std::chrono::steady_clock::time_point> ExpiresAt() const {
+ std::shared_lock lock(mutex_);
+ if (expires_at_ == std::chrono::steady_clock::time_point{}) {
+ return std::nullopt;
+ }
+ return expires_at_;
+ }
+
+ /// \brief Whether this session will keep refreshing its token.
+ ///
+ /// False if refresh is disabled, was stopped by StopRefreshing() or
Close(), or
+ /// gave up after repeated failures.
+ bool IsRefreshable() const {
+ std::lock_guard lock(refresh_mutex_);
+ return scheduler_.has_value() && !closed_ && !refresh_stopped_;
+ }
+
~OAuth2Session() override { std::ignore = CloseImpl(); }
private:
@@ -221,11 +256,18 @@ class OAuth2Session final : public AuthSession,
});
if (!result.has_value()) {
ICEBERG_LOG_WARN("Failed to refresh OAuth2 token: {}",
result.error().message);
+ MarkRefreshStopped();
return std::nullopt;
}
return *result;
}
+ // Record that no further refresh will be scheduled after a failure.
+ void MarkRefreshStopped() {
+ std::lock_guard lock(refresh_mutex_);
+ refresh_stopped_ = true;
+ }
+
Result<std::optional<std::chrono::milliseconds>> RefreshToken() {
{
std::lock_guard lock(refresh_mutex_);
@@ -276,6 +318,9 @@ class OAuth2Session final : public AuthSession,
ICEBERG_LOG_WARN(
"Stopping OAuth2 token refresh after repeated submission
failures: {}",
status.error().message);
+ if (auto self = weak_self.lock()) {
+ self->MarkRefreshStopped();
+ }
return;
}
if (auto self = weak_self.lock()) {
@@ -313,7 +358,9 @@ class OAuth2Session final : public AuthSession,
AuthProperties refresh_properties_;
std::shared_ptr<HttpClient> client_;
bool closed_ = false;
- std::mutex refresh_mutex_;
+ // Set when refresh gives up after repeated failures; guarded by
refresh_mutex_.
+ bool refresh_stopped_ = false;
+ mutable std::mutex refresh_mutex_;
std::condition_variable refresh_cv_;
int active_refresh_count_ = 0;
std::optional<::iceberg::internal::ScheduledExecutor> scheduler_;
diff --git a/src/iceberg/catalog/session_context.h
b/src/iceberg/catalog/session_context.h
index 19eb8fc5..1a6bc819 100644
--- a/src/iceberg/catalog/session_context.h
+++ b/src/iceberg/catalog/session_context.h
@@ -41,6 +41,9 @@ namespace iceberg {
/// silently overriding credentials.
struct ICEBERG_EXPORT SessionContext {
/// Unique session identifier. Explicit contexts must provide a non-empty ID.
+ ///
+ /// A session ID identifies a stable caller context. Its identity and
credentials
+ /// must not change while the ID is in use.
std::string session_id;
/// Caller identity. Empty means no identity was supplied. This value is
diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt
index 2513ff17..b2fd4f9b 100644
--- a/src/iceberg/test/CMakeLists.txt
+++ b/src/iceberg/test/CMakeLists.txt
@@ -345,6 +345,7 @@ if(ICEBERG_BUILD_REST)
add_rest_iceberg_test(rest_catalog_test
SOURCES
auth_manager_test.cc
+ auth_session_cache_test.cc
catalog_properties_test.cc
error_handlers_test.cc
endpoint_test.cc
diff --git a/src/iceberg/test/auth_manager_test.cc
b/src/iceberg/test/auth_manager_test.cc
index 0bae4306..076df633 100644
--- a/src/iceberg/test/auth_manager_test.cc
+++ b/src/iceberg/test/auth_manager_test.cc
@@ -359,6 +359,35 @@ TEST_F(AuthManagerTest, OAuth2StaticToken) {
"catalog-resource");
}
+TEST_F(AuthManagerTest, OAuth2ZeroSessionTimeoutCreatesCatalogSession) {
+ std::unordered_map<std::string, std::string> properties = {
+ {AuthProperties::kAuthType, "oauth2"},
+ {AuthProperties::kToken.key(), "my-static-token"},
+ {AuthProperties::kSessionTimeoutMs.key(), "0"},
+ };
+
+ ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog",
properties));
+ ICEBERG_UNWRAP_OR_FAIL(auto session, manager->CatalogSession(client_,
properties));
+
+ EXPECT_NE(session, nullptr);
+ EXPECT_THAT(manager->Close(), IsOk());
+}
+
+TEST_F(AuthManagerTest, OAuth2InvalidSessionTimeoutReturnsError) {
+ for (const std::string value : {"1h", "abc", "-1"}) {
+ std::unordered_map<std::string, std::string> properties = {
+ {AuthProperties::kAuthType, "oauth2"},
+ {AuthProperties::kToken.key(), "my-static-token"},
+ {AuthProperties::kSessionTimeoutMs.key(), value},
+ };
+
+ ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog",
properties));
+ EXPECT_THAT(manager->CatalogSession(client_, properties),
+ IsError(ErrorKind::kInvalidArgument))
+ << value;
+ }
+}
+
// Verifies OAuth2 type is inferred from token property
TEST_F(AuthManagerTest, OAuth2InferredFromToken) {
std::unordered_map<std::string, std::string> properties = {
@@ -483,6 +512,29 @@ TEST_F(AuthManagerTest, OAuth2TableTokenCreatesChild) {
EXPECT_TRUE(info->credential.empty());
}
+TEST_F(AuthManagerTest, OAuth2TableTokenSessionIsCachedByToken) {
+ std::unordered_map<std::string, std::string> properties = {
+ {AuthProperties::kAuthType, "oauth2"},
+ };
+ ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog",
properties));
+ ICEBERG_UNWRAP_OR_FAIL(auto parent, manager->CatalogSession(client_,
properties));
+ TableIdentifier table{.ns = Namespace{{"db"}}, .name = "table"};
+
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto first,
+ manager->TableSession(table, {{AuthProperties::kToken.key(),
"token-a"}}, parent));
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto second,
+ manager->TableSession(table, {{AuthProperties::kToken.key(),
"token-a"}}, parent));
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto other,
+ manager->TableSession(table, {{AuthProperties::kToken.key(),
"token-b"}}, parent));
+
+ EXPECT_EQ(first, second);
+ EXPECT_NE(first, other);
+ EXPECT_THAT(manager->Close(), IsOk());
+}
+
TEST_F(AuthManagerTest, OAuth2TableIgnoresCredential) {
std::unordered_map<std::string, std::string> properties = {
{AuthProperties::kAuthType, "oauth2"},
@@ -804,18 +856,75 @@ TEST(OAuth2SessionTest, StopsAfterHttpRetryBudget) {
});
auto client = std::make_shared<RefreshHttpClient>();
ICEBERG_UNWRAP_OR_FAIL(auto session, RefreshingSession(client, executor));
+ auto oauth2 = std::dynamic_pointer_cast<internal::OAuth2Session>(session);
+ ASSERT_NE(oauth2, nullptr);
ASSERT_EQ(task.wait_for(std::chrono::seconds(5)), std::future_status::ready);
+ EXPECT_TRUE(oauth2->IsRefreshable());
test::FakeRetryEnvironment fake_retry;
ScopedRetryTestHooks hooks(fake_retry.hooks());
std::move(task.get())();
EXPECT_EQ(client->requests, 6);
EXPECT_THAT(fake_retry.observed_base_delays_ms(),
::testing::ElementsAre(100, 200, 400, 800, 1600));
+ // No further refresh is scheduled, so the session no longer counts as
refreshable.
+ EXPECT_FALSE(oauth2->IsRefreshable());
ICEBERG_UNWRAP_OR_FAIL(auto request, session->Authenticate({}));
EXPECT_EQ(request.headers.at("Authorization"), "Bearer initial");
ASSERT_THAT(session->Close(), IsOk());
}
+TEST(OAuth2SessionTest, StopRefreshingKeepsSessionUsable) {
+ MockExecutor executor;
+ EXPECT_CALL(executor, Submit).Times(0);
+ OAuthTokenResponse token_response{
+ .access_token = "token",
+ .token_type = "bearer",
+ .expires_in_secs = 3600,
+ };
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto session,
+ internal::OAuth2Session::Make(token_response,
+ {.token_endpoint =
"http://localhost/oauth/tokens",
+ .client_id = "client_id",
+ .client_secret = "client_secret",
+ .scope = "catalog",
+ .refresh_executor = &executor},
+ std::make_shared<HttpClient>(),
std::nullopt));
+ EXPECT_TRUE(session->IsRefreshable());
+
+ session->StopRefreshing();
+ session->StopRefreshing();
+ EXPECT_FALSE(session->IsRefreshable());
+
+ ICEBERG_UNWRAP_OR_FAIL(auto request, session->Authenticate({}));
+ EXPECT_EQ(request.headers.at("Authorization"), "Bearer token");
+ EXPECT_THAT(session->Close(), IsOk());
+}
+
+TEST(OAuth2SessionTest, ExpiresAtReflectsTokenLifetime) {
+ auto make_session = [](std::optional<int64_t> expires_in_secs) {
+ OAuthTokenResponse token_response{
+ .access_token = "opaque-token",
+ .token_type = "bearer",
+ .expires_in_secs = expires_in_secs,
+ };
+ return internal::OAuth2Session::Make(
+ token_response, {.token_endpoint = "http://localhost/oauth/tokens"},
+ std::make_shared<HttpClient>(), std::nullopt);
+ };
+
+ auto before = std::chrono::steady_clock::now();
+ ICEBERG_UNWRAP_OR_FAIL(auto expiring, make_session(60));
+ auto after = std::chrono::steady_clock::now();
+ auto expires_at = expiring->ExpiresAt();
+ ASSERT_TRUE(expires_at.has_value());
+ EXPECT_GE(*expires_at, before + std::chrono::seconds(60));
+ EXPECT_LE(*expires_at, after + std::chrono::seconds(60));
+
+ ICEBERG_UNWRAP_OR_FAIL(auto unknown, make_session(std::nullopt));
+ EXPECT_FALSE(unknown->ExpiresAt().has_value());
+}
+
TEST(OAuth2SessionTest, InitTokenExpirationUsesRequestStartTime) {
OAuthTokenResponse token_response{
.access_token = "opaque-token",
@@ -847,4 +956,214 @@ TEST(OAuth2SessionTest,
JwtExpirationTakesPriorityOverExpiresIn) {
EXPECT_EQ(*expiration,
std::chrono::steady_clock::time_point(std::chrono::seconds(70)));
}
+namespace {
+
+/// Creates a credential child session from a static-token catalog whose token
+/// endpoint always succeeds.
+Result<std::shared_ptr<internal::OAuth2Session>> CredentialChildSession(
+ AuthManager& manager) {
+ auto client = std::make_shared<RefreshHttpClient>();
+ client->response = []() -> Result<HttpResponse> {
+ return HttpResponse(
+ 200,
R"({"access_token":"child-token","token_type":"bearer","expires_in":3600})");
+ };
+ std::unordered_map<std::string, std::string> properties = {
+ {AuthProperties::kAuthType, "oauth2"},
+ {AuthProperties::kToken.key(), "catalog-token"},
+ };
+ ICEBERG_ASSIGN_OR_RAISE(auto parent, manager.CatalogSession(client,
properties));
+ SessionContext context{
+ .session_id = "tenant-a",
+ .credentials = {{AuthProperties::kCredential.key(), "client:secret"}},
+ };
+ ICEBERG_ASSIGN_OR_RAISE(auto child, manager.ContextualSession(context,
parent));
+ auto oauth2 = std::dynamic_pointer_cast<internal::OAuth2Session>(child);
+ ICEBERG_PRECHECK(oauth2 != nullptr, "child session is not an OAuth2
session");
+ return oauth2;
+}
+
+} // namespace
+
+TEST(OAuth2ChildSessionTest, CredentialChildRefreshesWithExecutor) {
+ // The refresh is due long after this test ends, so nothing is submitted.
+ MockExecutor executor;
+ EXPECT_CALL(executor, Submit).Times(0);
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto manager,
+ AuthManagers::Load("test-catalog", {{AuthProperties::kAuthType,
"oauth2"}}));
+ manager->SetAuthRefreshExecutor(&executor);
+
+ ICEBERG_UNWRAP_OR_FAIL(auto child, CredentialChildSession(*manager));
+
+ EXPECT_TRUE(child->IsRefreshable());
+ ICEBERG_UNWRAP_OR_FAIL(auto request, child->Authenticate({}));
+ EXPECT_EQ(request.headers.at("Authorization"), "Bearer child-token");
+ EXPECT_THAT(manager->Close(), IsOk());
+}
+
+TEST(OAuth2ChildSessionTest, CredentialChildWithoutExecutorDoesNotRefresh) {
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto manager,
+ AuthManagers::Load("test-catalog", {{AuthProperties::kAuthType,
"oauth2"}}));
+
+ ICEBERG_UNWRAP_OR_FAIL(auto child, CredentialChildSession(*manager));
+
+ EXPECT_FALSE(child->IsRefreshable());
+ ICEBERG_UNWRAP_OR_FAIL(auto request, child->Authenticate({}));
+ EXPECT_EQ(request.headers.at("Authorization"), "Bearer child-token");
+ EXPECT_THAT(manager->Close(), IsOk());
+}
+
+TEST(OAuth2ChildSessionTest, ContextSessionIsReloadedAfterRefreshGivesUp) {
+ MockExecutor executor;
+ std::promise<ExecutorTask> queued;
+ auto task = queued.get_future();
+ EXPECT_CALL(executor, Submit).WillOnce([&](ExecutorTask work) -> Status {
+ queued.set_value(std::move(work));
+ return {};
+ });
+ // The token endpoint first issues an already expired token, then fails
until it
+ // recovers.
+ auto client = std::make_shared<RefreshHttpClient>();
+ std::atomic<bool> recovered = false;
+ client->response = [&]() -> Result<HttpResponse> {
+ if (client->requests == 1) {
+ return HttpResponse(
+ 200,
R"({"access_token":"expired","token_type":"bearer","expires_in":0})");
+ }
+ if (!recovered) {
+ return IOError("token endpoint unavailable");
+ }
+ return HttpResponse(
+ 200,
R"({"access_token":"fresh","token_type":"bearer","expires_in":3600})");
+ };
+ std::unordered_map<std::string, std::string> properties = {
+ {AuthProperties::kAuthType, "oauth2"},
+ {AuthProperties::kToken.key(), "catalog-token"},
+ };
+ ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog",
properties));
+ manager->SetAuthRefreshExecutor(&executor);
+ ICEBERG_UNWRAP_OR_FAIL(auto parent, manager->CatalogSession(client,
properties));
+ SessionContext context{
+ .session_id = "tenant-a",
+ .credentials = {{AuthProperties::kCredential.key(), "client:secret"}},
+ };
+
+ ICEBERG_UNWRAP_OR_FAIL(auto first, manager->ContextualSession(context,
parent));
+ ASSERT_EQ(task.wait_for(std::chrono::seconds(5)), std::future_status::ready);
+ // While its refresh is pending, the expired session stays cached.
+ ICEBERG_UNWRAP_OR_FAIL(auto pending, manager->ContextualSession(context,
parent));
+ EXPECT_EQ(pending, first);
+
+ {
+ test::FakeRetryEnvironment fake_retry;
+ ScopedRetryTestHooks hooks(fake_retry.hooks());
+ std::move(task.get())(); // every refresh attempt fails
+ }
+ recovered = true;
+
+ // The refresh gave up and the token has expired, so a new session is loaded.
+ ICEBERG_UNWRAP_OR_FAIL(auto reloaded, manager->ContextualSession(context,
parent));
+ EXPECT_NE(reloaded, first);
+ ICEBERG_UNWRAP_OR_FAIL(auto request, reloaded->Authenticate({}));
+ EXPECT_EQ(request.headers.at("Authorization"), "Bearer fresh");
+ EXPECT_THAT(manager->Close(), IsOk());
+}
+
+namespace {
+
+/// Answers token exchanges with a token that records the subject and actor.
+class ExchangeHttpClient : public HttpClient {
+ public:
+ Result<HttpResponse> PostForm(const std::string&,
+ const std::unordered_map<std::string,
std::string>& form,
+ const std::unordered_map<std::string,
std::string>&,
+ const ErrorHandler&, AuthSession&) override {
+ ++requests;
+ auto actor = form.find("actor_token");
+ std::string token = "sub=" + form.at("subject_token") + ",act=" +
+ (actor == form.end() ? std::string("none") :
actor->second);
+ return HttpResponse(200, R"({"access_token":")" + token +
+
R"(","token_type":"bearer","issued_token_type":")" +
+ AuthProperties::kAccessTokenType + R"("})");
+ }
+ std::atomic<int> requests{0};
+};
+
+class TableSessionCacheKeyTest : public ::testing::Test {
+ protected:
+ void SetUp() override {
+ ICEBERG_UNWRAP_OR_FAIL(
+ manager_,
+ AuthManagers::Load("test-catalog", {{AuthProperties::kAuthType,
"oauth2"}}));
+ ICEBERG_UNWRAP_OR_FAIL(
+ catalog_, manager_->CatalogSession(
+ client_, {{AuthProperties::kAuthType, "oauth2"},
+ {AuthProperties::kToken.key(),
"catalog-token"}}));
+ }
+
+ void TearDown() override { EXPECT_THAT(manager_->Close(), IsOk()); }
+
+ /// A contextual session holding `token`, used as a table session parent.
+ Result<std::shared_ptr<AuthSession>> Parent(const std::string& session_id,
+ const std::string& token) {
+ return manager_->ContextualSession(
+ {.session_id = session_id,
+ .credentials = {{AuthProperties::kToken.key(), token}}},
+ catalog_);
+ }
+
+ Result<std::shared_ptr<AuthSession>> Table(
+ const std::unordered_map<std::string, std::string>& config,
+ std::shared_ptr<AuthSession> parent) {
+ return manager_->TableSession(table_, config, std::move(parent));
+ }
+
+ static std::string Authorization(AuthSession& session) {
+ auto request = session.Authenticate({});
+ return request.has_value() ? request->headers.at("Authorization") : "";
+ }
+
+ std::shared_ptr<ExchangeHttpClient> client_ =
std::make_shared<ExchangeHttpClient>();
+ std::unique_ptr<AuthManager> manager_;
+ std::shared_ptr<AuthSession> catalog_;
+ TableIdentifier table_{.ns = Namespace{{"db"}}, .name = "orders"};
+};
+
+} // namespace
+
+TEST_F(TableSessionCacheKeyTest, ExchangedSessionsAreKeyedByActor) {
+ ICEBERG_UNWRAP_OR_FAIL(auto alice, Parent("alice", "alice-token"));
+ ICEBERG_UNWRAP_OR_FAIL(auto bob, Parent("bob", "bob-token"));
+ std::unordered_map<std::string, std::string> config = {
+ {AuthProperties::kJwtTokenType, "table-jwt"}};
+
+ ICEBERG_UNWRAP_OR_FAIL(auto alice_first, Table(config, alice));
+ ICEBERG_UNWRAP_OR_FAIL(auto alice_again, Table(config, alice));
+ ICEBERG_UNWRAP_OR_FAIL(auto bob_session, Table(config, bob));
+
+ EXPECT_EQ(alice_again, alice_first);
+ EXPECT_NE(bob_session, alice_first);
+ EXPECT_EQ(client_->requests, 2);
+ EXPECT_EQ(Authorization(*alice_first), "Bearer
sub=table-jwt,act=alice-token");
+ EXPECT_EQ(Authorization(*bob_session), "Bearer sub=table-jwt,act=bob-token");
+}
+
+TEST_F(TableSessionCacheKeyTest, SessionsAreKeyedByFlowAndTokenType) {
+ ICEBERG_UNWRAP_OR_FAIL(auto alice, Parent("alice", "alice-token"));
+
+ ICEBERG_UNWRAP_OR_FAIL(auto bearer,
+ Table({{AuthProperties::kToken.key(), "same-value"}},
alice));
+ ICEBERG_UNWRAP_OR_FAIL(auto jwt,
+ Table({{AuthProperties::kJwtTokenType,
"same-value"}}, alice));
+ ICEBERG_UNWRAP_OR_FAIL(auto saml2,
+ Table({{AuthProperties::kSaml2TokenType,
"same-value"}}, alice));
+
+ EXPECT_NE(jwt, bearer);
+ EXPECT_NE(saml2, jwt);
+ EXPECT_EQ(client_->requests, 2);
+ EXPECT_EQ(Authorization(*bearer), "Bearer same-value");
+ EXPECT_EQ(Authorization(*jwt), "Bearer sub=same-value,act=alice-token");
+}
+
} // namespace iceberg::rest::auth
diff --git a/src/iceberg/test/auth_session_cache_test.cc
b/src/iceberg/test/auth_session_cache_test.cc
new file mode 100644
index 00000000..074459e7
--- /dev/null
+++ b/src/iceberg/test/auth_session_cache_test.cc
@@ -0,0 +1,582 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include <atomic>
+#include <chrono>
+#include <condition_variable>
+#include <cstdint>
+#include <future>
+#include <memory>
+#include <mutex>
+#include <set>
+#include <stdexcept>
+#include <string>
+#include <thread>
+#include <vector>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+
+#include "iceberg/catalog/rest/auth/auth_session.h"
+#include "iceberg/catalog/rest/auth/auth_session_cache_internal.h"
+#include "iceberg/catalog/rest/auth/auth_session_internal.h"
+#include "iceberg/catalog/rest/http_client.h"
+#include "iceberg/test/executor.h"
+#include "iceberg/test/matchers.h"
+
+namespace iceberg::rest::auth {
+
+namespace {
+
+using internal::AuthSessionCache;
+using namespace std::chrono_literals;
+
+constexpr auto kTimeout = std::chrono::milliseconds(1000);
+constexpr auto kTokenLifetime = std::chrono::seconds(60);
+
+/// Starts at the real steady clock so it is comparable with
OAuth2Session::ExpiresAt().
+class FakeClock {
+ public:
+ std::chrono::steady_clock::time_point Now() const {
+ return
std::chrono::steady_clock::time_point(std::chrono::milliseconds(now_ms_));
+ }
+
+ AuthSessionCache::Clock AsClock() {
+ return [this] { return Now(); };
+ }
+
+ void Advance(std::chrono::milliseconds delta) { now_ms_ += delta.count(); }
+
+ private:
+ std::atomic<int64_t>
now_ms_{std::chrono::duration_cast<std::chrono::milliseconds>(
+
std::chrono::steady_clock::now().time_since_epoch())
+ .count()};
+};
+
+class RemovalRecorder {
+ public:
+ AuthSessionCache::RemovalListener Listener() {
+ return [this](const std::shared_ptr<AuthSession>& session) {
+ std::lock_guard lock(mutex_);
+ removed_.push_back(session);
+ };
+ }
+
+ std::vector<std::shared_ptr<AuthSession>> removed() const {
+ std::lock_guard lock(mutex_);
+ return removed_;
+ }
+
+ private:
+ mutable std::mutex mutex_;
+ std::vector<std::shared_ptr<AuthSession>> removed_;
+};
+
+/// One-shot latch used to hold a loader inside Get().
+class Gate {
+ public:
+ void Open() {
+ {
+ std::lock_guard lock(mutex_);
+ open_ = true;
+ }
+ cv_.notify_all();
+ }
+
+ void Wait() {
+ std::unique_lock lock(mutex_);
+ cv_.wait(lock, [this] { return open_; });
+ }
+
+ private:
+ std::mutex mutex_;
+ std::condition_variable cv_;
+ bool open_ = false;
+};
+
+AuthSessionCache::Loader CountingLoader(std::atomic<int>& calls) {
+ return [&calls]() -> Result<std::shared_ptr<AuthSession>> {
+ ++calls;
+ return AuthSession::MakeDefault({});
+ };
+}
+
+/// Loads an OAuth2 session whose token expires kTokenLifetime after `clock`
reads.
+/// The session refreshes on `refresh_executor` when it is not null.
+AuthSessionCache::Loader OAuth2Loader(std::atomic<int>& calls, const
FakeClock& clock,
+ Executor* refresh_executor) {
+ return [&calls, &clock, refresh_executor]() ->
Result<std::shared_ptr<AuthSession>> {
+ ++calls;
+ OAuthTokenResponse token_response{
+ .access_token = "opaque-token",
+ .token_type = "bearer",
+ .expires_in_secs = kTokenLifetime.count(),
+ };
+ ICEBERG_ASSIGN_OR_RAISE(
+ auto session,
+ internal::OAuth2Session::Make(token_response,
+ {.token_endpoint =
"http://localhost/oauth/tokens",
+ .refresh_executor = refresh_executor},
+ std::make_shared<HttpClient>(),
clock.Now()));
+ return session;
+ };
+}
+
+} // namespace
+
+class AuthSessionCacheTest : public ::testing::Test {
+ protected:
+ std::shared_ptr<AuthSessionCache> MakeCache(
+ std::chrono::milliseconds timeout = kTimeout) {
+ auto cache = AuthSessionCache::Make(timeout, recorder_.Listener(),
clock_.AsClock());
+ EXPECT_THAT(cache, IsOk());
+ return cache.value();
+ }
+
+ // Declared before any cache so they outlive it.
+ FakeClock clock_;
+ RemovalRecorder recorder_;
+};
+
+TEST_F(AuthSessionCacheTest, RejectsNegativeTimeout) {
+ EXPECT_THAT(AuthSessionCache::Make(-1ms, recorder_.Listener()),
+ IsError(ErrorKind::kInvalidArgument));
+}
+
+TEST_F(AuthSessionCacheTest, ReturnsCachedSessionOnHit) {
+ auto cache = MakeCache();
+ std::atomic<int> calls = 0;
+
+ ICEBERG_UNWRAP_OR_FAIL(auto first, cache->Get("tenant-a",
CountingLoader(calls)));
+ ICEBERG_UNWRAP_OR_FAIL(auto second, cache->Get("tenant-a",
CountingLoader(calls)));
+
+ EXPECT_EQ(first, second);
+ EXPECT_EQ(calls, 1);
+ EXPECT_EQ(cache->size(), 1);
+}
+
+TEST_F(AuthSessionCacheTest, KeysAreIsolated) {
+ auto cache = MakeCache();
+ std::atomic<int> calls = 0;
+
+ ICEBERG_UNWRAP_OR_FAIL(auto a, cache->Get("tenant-a",
CountingLoader(calls)));
+ ICEBERG_UNWRAP_OR_FAIL(auto b, cache->Get("tenant-b",
CountingLoader(calls)));
+
+ EXPECT_NE(a, b);
+ EXPECT_EQ(calls, 2);
+}
+
+TEST_F(AuthSessionCacheTest, FailedLoadIsNotCached) {
+ auto cache = MakeCache();
+ auto failing = []() -> Result<std::shared_ptr<AuthSession>> {
+ return AuthenticationFailed("token endpoint unavailable");
+ };
+ std::atomic<int> calls = 0;
+
+ EXPECT_THAT(cache->Get("tenant-a", failing),
IsError(ErrorKind::kAuthenticationFailed));
+ EXPECT_EQ(cache->size(), 0);
+ EXPECT_THAT(cache->Get("tenant-a", CountingLoader(calls)), IsOk());
+ EXPECT_EQ(calls, 1);
+}
+
+TEST_F(AuthSessionCacheTest, NullSessionIsRejected) {
+ auto cache = MakeCache();
+ auto null_loader = []() -> Result<std::shared_ptr<AuthSession>> { return
nullptr; };
+
+ EXPECT_THAT(cache->Get("tenant-a", null_loader),
IsError(ErrorKind::kInvalid));
+ EXPECT_EQ(cache->size(), 0);
+}
+
+TEST_F(AuthSessionCacheTest, ExpiresAfterIdleTimeout) {
+ auto cache = MakeCache();
+ std::atomic<int> calls = 0;
+ ICEBERG_UNWRAP_OR_FAIL(auto first, cache->Get("tenant-a",
CountingLoader(calls)));
+
+ // Each access resets the idle timer, so the entry outlives the timeout in
total.
+ clock_.Advance(kTimeout - 1ms);
+ ICEBERG_UNWRAP_OR_FAIL(auto hit, cache->Get("tenant-a",
CountingLoader(calls)));
+ clock_.Advance(kTimeout - 1ms);
+ ICEBERG_UNWRAP_OR_FAIL(auto still_hit, cache->Get("tenant-a",
CountingLoader(calls)));
+ EXPECT_EQ(hit, first);
+ EXPECT_EQ(still_hit, first);
+ EXPECT_EQ(calls, 1);
+
+ clock_.Advance(kTimeout);
+ ICEBERG_UNWRAP_OR_FAIL(auto reloaded, cache->Get("tenant-a",
CountingLoader(calls)));
+ EXPECT_NE(reloaded, first);
+ EXPECT_EQ(calls, 2);
+ EXPECT_THAT(recorder_.removed(), ::testing::ElementsAre(first));
+}
+
+TEST_F(AuthSessionCacheTest, SweepRemovesOnlyExpiredEntries) {
+ auto cache = MakeCache();
+ std::atomic<int> calls = 0;
+ ICEBERG_UNWRAP_OR_FAIL(auto idle, cache->Get("idle", CountingLoader(calls)));
+ ICEBERG_UNWRAP_OR_FAIL(auto active, cache->Get("active",
CountingLoader(calls)));
+
+ clock_.Advance(kTimeout - 1ms);
+ ASSERT_THAT(cache->Get("active", CountingLoader(calls)), IsOk());
+ clock_.Advance(1ms);
+ cache->Sweep();
+
+ EXPECT_EQ(cache->size(), 1);
+ EXPECT_THAT(recorder_.removed(), ::testing::ElementsAre(idle));
+ ICEBERG_UNWRAP_OR_FAIL(auto still_active, cache->Get("active",
CountingLoader(calls)));
+ EXPECT_EQ(still_active, active);
+}
+
+TEST_F(AuthSessionCacheTest, NonRefreshableSessionExpiresWithToken) {
+ auto cache = MakeCache(std::chrono::hours(1));
+ std::atomic<int> calls = 0;
+ auto loader = OAuth2Loader(calls, clock_, /*refresh_executor=*/nullptr);
+ ICEBERG_UNWRAP_OR_FAIL(auto first, cache->Get("tenant-a", loader));
+
+ clock_.Advance(kTokenLifetime - 1ms);
+ ICEBERG_UNWRAP_OR_FAIL(auto hit, cache->Get("tenant-a", loader));
+ EXPECT_EQ(hit, first);
+
+ // Sweep() only reclaims idle entries; the expired token is replaced on
access.
+ clock_.Advance(1ms);
+ cache->Sweep();
+ EXPECT_EQ(cache->size(), 1);
+ EXPECT_TRUE(recorder_.removed().empty());
+
+ ICEBERG_UNWRAP_OR_FAIL(auto reloaded, cache->Get("tenant-a", loader));
+ EXPECT_NE(reloaded, first);
+ EXPECT_EQ(calls, 2);
+ EXPECT_THAT(recorder_.removed(), ::testing::ElementsAre(first));
+}
+
+TEST_F(AuthSessionCacheTest, RefreshableSessionOutlivesToken) {
+ // Must outlive the cache. The refresh is due long after this test ends, so
+ // nothing is submitted.
+ test::MockExecutor executor;
+ EXPECT_CALL(executor, Submit).Times(0);
+ auto cache = MakeCache(std::chrono::hours(1));
+ std::atomic<int> calls = 0;
+ auto loader = OAuth2Loader(calls, clock_, &executor);
+ ICEBERG_UNWRAP_OR_FAIL(auto first, cache->Get("tenant-a", loader));
+
+ // Its refresh may still be in flight; eviction would stop it for other
holders.
+ clock_.Advance(kTokenLifetime + 1s);
+ cache->Sweep();
+ ICEBERG_UNWRAP_OR_FAIL(auto hit, cache->Get("tenant-a", loader));
+
+ EXPECT_EQ(hit, first);
+ EXPECT_EQ(calls, 1);
+ EXPECT_TRUE(recorder_.removed().empty());
+}
+
+TEST_F(AuthSessionCacheTest, GetEvictsIdleEntriesWithoutPeriodicSweep) {
+ auto cache = MakeCache();
+ std::atomic<int> calls = 0;
+ ICEBERG_UNWRAP_OR_FAIL(auto a, cache->Get("a", CountingLoader(calls)));
+ ICEBERG_UNWRAP_OR_FAIL(auto b, cache->Get("b", CountingLoader(calls)));
+ ICEBERG_UNWRAP_OR_FAIL(auto c, cache->Get("c", CountingLoader(calls)));
+
+ // Keys that are never accessed again are reclaimed when another key is
added.
+ clock_.Advance(kTimeout);
+ ASSERT_THAT(cache->Get("new", CountingLoader(calls)), IsOk());
+
+ EXPECT_EQ(cache->size(), 1);
+ EXPECT_THAT(recorder_.removed(), ::testing::UnorderedElementsAre(a, b, c));
+}
+
+TEST_F(AuthSessionCacheTest, EvictionFollowsAccessOrder) {
+ auto cache = MakeCache();
+ std::atomic<int> calls = 0;
+ ICEBERG_UNWRAP_OR_FAIL(auto a, cache->Get("a", CountingLoader(calls)));
+ ICEBERG_UNWRAP_OR_FAIL(auto b, cache->Get("b", CountingLoader(calls)));
+ ICEBERG_UNWRAP_OR_FAIL(auto c, cache->Get("c", CountingLoader(calls)));
+
+ // Accessing a moves it behind b and c, so only b and c become idle.
+ clock_.Advance(kTimeout / 2);
+ ASSERT_THAT(cache->Get("a", CountingLoader(calls)), IsOk());
+ clock_.Advance(kTimeout * 6 / 10);
+ cache->Sweep();
+
+ EXPECT_EQ(cache->size(), 1);
+ EXPECT_THAT(recorder_.removed(), ::testing::ElementsAre(b, c));
+ ICEBERG_UNWRAP_OR_FAIL(auto still_cached, cache->Get("a",
CountingLoader(calls)));
+ EXPECT_EQ(still_cached, a);
+ EXPECT_EQ(calls, 3);
+}
+
+TEST_F(AuthSessionCacheTest, GetDoesNotEvictWithPeriodicSweep) {
+ test::ThreadExecutor executor; // must outlive the cache
+ auto cache = MakeCache();
+ ASSERT_THAT(cache->StartPeriodicSweep(executor, std::chrono::hours(1)),
IsOk());
+ std::atomic<int> calls = 0;
+ ASSERT_THAT(cache->Get("a", CountingLoader(calls)), IsOk());
+
+ // Idle entries are left to the periodic sweep.
+ clock_.Advance(kTimeout * 2);
+ ASSERT_THAT(cache->Get("b", CountingLoader(calls)), IsOk());
+
+ EXPECT_EQ(cache->size(), 2);
+ EXPECT_TRUE(recorder_.removed().empty());
+ cache->Close();
+}
+
+TEST_F(AuthSessionCacheTest, ConcurrentGetsForSameKeyLoadOnce) {
+ auto cache = MakeCache();
+ std::atomic<int> calls = 0;
+ Gate entered;
+ Gate release;
+ auto blocking_loader = [&]() -> Result<std::shared_ptr<AuthSession>> {
+ ++calls;
+ entered.Open();
+ release.Wait();
+ return AuthSession::MakeDefault({});
+ };
+
+ constexpr int kThreads = 8;
+ std::vector<std::shared_ptr<AuthSession>> results(kThreads);
+ std::vector<std::thread> threads;
+ for (int i = 0; i < kThreads; ++i) {
+ threads.emplace_back([&, i] {
+ auto session = cache->Get("tenant-a", blocking_loader);
+ if (session.has_value()) {
+ results[i] = session.value();
+ }
+ });
+ }
+ entered.Wait();
+ std::this_thread::sleep_for(20ms); // let the other callers queue up
+ release.Open();
+ for (auto& thread : threads) {
+ thread.join();
+ }
+
+ EXPECT_EQ(calls, 1);
+ ASSERT_NE(results[0], nullptr);
+ for (const auto& session : results) {
+ EXPECT_EQ(session, results[0]);
+ }
+}
+
+TEST_F(AuthSessionCacheTest, WaitersRetryAfterFailedLoad) {
+ auto cache = MakeCache();
+ std::atomic<int> calls = 0;
+ Gate entered;
+ Gate release;
+ auto loader = [&]() -> Result<std::shared_ptr<AuthSession>> {
+ if (++calls == 1) {
+ entered.Open();
+ release.Wait();
+ return AuthenticationFailed("first attempt fails");
+ }
+ return AuthSession::MakeDefault({});
+ };
+
+ Result<std::shared_ptr<AuthSession>> first_result = nullptr;
+ std::thread first([&] { first_result = cache->Get("tenant-a", loader); });
+ entered.Wait();
+ Result<std::shared_ptr<AuthSession>> waiter_result = nullptr;
+ std::thread waiter([&] { waiter_result = cache->Get("tenant-a", loader); });
+ std::this_thread::sleep_for(20ms);
+ release.Open();
+ first.join();
+ waiter.join();
+
+ EXPECT_THAT(first_result, IsError(ErrorKind::kAuthenticationFailed));
+ EXPECT_THAT(waiter_result, IsOk());
+ EXPECT_EQ(calls, 2);
+ EXPECT_EQ(cache->size(), 1);
+}
+
+TEST_F(AuthSessionCacheTest, LoaderExceptionDoesNotBlockLaterGets) {
+ auto cache = MakeCache();
+ std::atomic<int> calls = 0;
+ Gate entered;
+ Gate release;
+ auto loader = [&]() -> Result<std::shared_ptr<AuthSession>> {
+ if (++calls == 1) {
+ entered.Open();
+ release.Wait();
+ throw std::runtime_error("HTTP library failure");
+ }
+ return AuthSession::MakeDefault({});
+ };
+
+ auto first =
+ std::async(std::launch::async, [&] { return cache->Get("tenant-a",
loader); });
+ entered.Wait();
+ auto waiter =
+ std::async(std::launch::async, [&] { return cache->Get("tenant-a",
loader); });
+ std::this_thread::sleep_for(20ms);
+ release.Open();
+
+ if (waiter.wait_for(5s) != std::future_status::ready) {
+ cache->Close(); // Wake the waiter so the test fails instead of hanging.
+ FAIL() << "a waiter stayed blocked after the loader threw";
+ }
+ auto first_result = first.get();
+ ASSERT_THAT(first_result, IsError(ErrorKind::kUnknownError));
+ EXPECT_THAT(first_result.error().message, ::testing::HasSubstr("HTTP library
failure"));
+ ICEBERG_UNWRAP_OR_FAIL(auto loaded, waiter.get());
+
+ ICEBERG_UNWRAP_OR_FAIL(auto hit, cache->Get("tenant-a", loader));
+ EXPECT_EQ(hit, loaded);
+ EXPECT_EQ(calls, 2);
+ EXPECT_EQ(cache->size(), 1);
+}
+
+TEST_F(AuthSessionCacheTest, CloseRemovesAllEntriesOnce) {
+ auto cache = MakeCache();
+ std::atomic<int> calls = 0;
+ ICEBERG_UNWRAP_OR_FAIL(auto a, cache->Get("tenant-a",
CountingLoader(calls)));
+ ICEBERG_UNWRAP_OR_FAIL(auto b, cache->Get("tenant-b",
CountingLoader(calls)));
+
+ cache->Close();
+ cache->Close();
+
+ EXPECT_EQ(cache->size(), 0);
+ EXPECT_THAT(recorder_.removed(), ::testing::UnorderedElementsAre(a, b));
+ EXPECT_THAT(cache->Get("tenant-a", CountingLoader(calls)),
+ IsError(ErrorKind::kInvalid));
+ EXPECT_EQ(calls, 2);
+}
+
+TEST_F(AuthSessionCacheTest, LoadFinishingAfterCloseIsRemovedAndWaitersFail) {
+ auto cache = MakeCache();
+ Gate entered;
+ Gate release;
+ std::shared_ptr<AuthSession> loaded;
+ auto loader = [&]() -> Result<std::shared_ptr<AuthSession>> {
+ entered.Open();
+ release.Wait();
+ loaded = AuthSession::MakeDefault({});
+ return loaded;
+ };
+
+ Result<std::shared_ptr<AuthSession>> loader_result = nullptr;
+ std::thread loading([&] { loader_result = cache->Get("tenant-a", loader); });
+ entered.Wait();
+ Result<std::shared_ptr<AuthSession>> waiter_result = nullptr;
+ std::thread waiter([&] { waiter_result = cache->Get("tenant-a", loader); });
+ std::this_thread::sleep_for(20ms);
+
+ cache->Close();
+ waiter.join(); // Close() wakes waiters before the load finishes.
+ EXPECT_THAT(waiter_result, IsError(ErrorKind::kInvalid));
+
+ release.Open();
+ loading.join();
+ EXPECT_THAT(loader_result, IsError(ErrorKind::kInvalid));
+ EXPECT_EQ(cache->size(), 0);
+ EXPECT_THAT(recorder_.removed(), ::testing::ElementsAre(loaded));
+}
+
+TEST_F(AuthSessionCacheTest, EverySessionIsRemovedExactlyOnceUnderContention) {
+ auto cache = MakeCache();
+ std::mutex loaded_mutex;
+ std::set<std::shared_ptr<AuthSession>> loaded;
+ auto loader = [&]() -> Result<std::shared_ptr<AuthSession>> {
+ auto session = AuthSession::MakeDefault({});
+ std::lock_guard lock(loaded_mutex);
+ loaded.insert(session);
+ return session;
+ };
+
+ std::atomic<bool> stop = false;
+ std::thread sweeper([&] {
+ while (!stop) {
+ cache->Sweep();
+ std::this_thread::yield();
+ }
+ });
+ std::vector<std::thread> workers;
+ for (int t = 0; t < 4; ++t) {
+ workers.emplace_back([&, t] {
+ for (int i = 0; i < 500; ++i) {
+ EXPECT_THAT(cache->Get("key-" + std::to_string((i + t) % 5), loader),
IsOk());
+ if (i % 10 == 0) {
+ clock_.Advance(kTimeout / 3);
+ }
+ }
+ });
+ }
+ for (auto& worker : workers) {
+ worker.join();
+ }
+ stop = true;
+ sweeper.join();
+ cache->Close();
+
+ auto removed = recorder_.removed();
+ std::set<std::shared_ptr<AuthSession>> unique_removed(removed.begin(),
removed.end());
+ EXPECT_EQ(unique_removed.size(), removed.size()) << "a session was removed
twice";
+ EXPECT_EQ(unique_removed, loaded);
+}
+
+TEST_F(AuthSessionCacheTest, PeriodicSweepRemovesExpiredEntries) {
+ test::ThreadExecutor executor; // must outlive the cache
+ auto cache = MakeCache();
+ std::atomic<int> calls = 0;
+ ICEBERG_UNWRAP_OR_FAIL(auto idle, cache->Get("idle", CountingLoader(calls)));
+ clock_.Advance(kTimeout);
+
+ ASSERT_THAT(cache->StartPeriodicSweep(executor, 10ms), IsOk());
+
+ // Sweep invokes the listener after erasing the entry, so wait for the
listener.
+ auto deadline = std::chrono::steady_clock::now() + 5s;
+ while (recorder_.removed().empty() && std::chrono::steady_clock::now() <
deadline) {
+ std::this_thread::sleep_for(5ms);
+ }
+ EXPECT_EQ(cache->size(), 0);
+ EXPECT_THAT(recorder_.removed(), ::testing::ElementsAre(idle));
+}
+
+TEST_F(AuthSessionCacheTest, PeriodicSweepContinuesAfterSubmissionFailure) {
+ test::ThreadExecutor executor(IOError("executor rejected the task"));
+ auto cache = MakeCache();
+ ASSERT_THAT(cache->StartPeriodicSweep(executor, 10ms), IsOk());
+
+ auto deadline = std::chrono::steady_clock::now() + 5s;
+ while (executor.submit_count() < 3 && std::chrono::steady_clock::now() <
deadline) {
+ std::this_thread::sleep_for(5ms);
+ }
+ EXPECT_GE(executor.submit_count(), 3);
+ cache->Close();
+}
+
+TEST_F(AuthSessionCacheTest, PeriodicSweepArgumentsAndLifecycle) {
+ test::ThreadExecutor executor;
+ auto cache = MakeCache();
+
+ EXPECT_THAT(cache->StartPeriodicSweep(executor, 0ms),
+ IsError(ErrorKind::kInvalidArgument));
+ EXPECT_THAT(cache->StartPeriodicSweep(executor, 10ms), IsOk());
+ EXPECT_THAT(cache->StartPeriodicSweep(executor, 10ms), IsOk()); // no-op
+
+ // Destroying the cache cancels the pending sweep.
+ cache.reset();
+ int submitted = executor.submit_count();
+ ASSERT_TRUE(test::WaitForSharedTimer(30ms));
+ EXPECT_EQ(executor.submit_count(), submitted);
+
+ auto closed = MakeCache();
+ closed->Close();
+ EXPECT_THAT(closed->StartPeriodicSweep(executor, 10ms), IsOk()); // no-op
+ ASSERT_TRUE(test::WaitForSharedTimer(30ms));
+ EXPECT_EQ(executor.submit_count(), submitted);
+}
+
+} // namespace iceberg::rest::auth
diff --git a/src/iceberg/test/rest_catalog_integration_test.cc
b/src/iceberg/test/rest_catalog_integration_test.cc
index c03c9c27..0e3ba6a3 100644
--- a/src/iceberg/test/rest_catalog_integration_test.cc
+++ b/src/iceberg/test/rest_catalog_integration_test.cc
@@ -313,6 +313,13 @@ TEST_F(RestCatalogIntegrationTest,
OAuthTableTypedTokenEndToEnd) {
auto info = child->OAuth2Info();
ASSERT_TRUE(info.has_value());
EXPECT_EQ(info->issued_token_type, auth::AuthProperties::kAccessTokenType);
+
+ // The same typed token reuses the cached session instead of exchanging
again.
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto cached,
+ manager->TableSession(
+ table, {{auth::AuthProperties::kJwtTokenType, "table-jwt-token"}},
parent));
+ EXPECT_EQ(cached, child);
}
TEST_F(RestCatalogIntegrationTest, OAuthTokenExchangeWithoutActorEndToEnd) {