Skip to content

Commit 7d352f5

Browse files
authored
feat(rest): implement OAuth2 token auto-refresh for REST catalog (#646)
Replace the MakeOAuth2 stub with a full OAuth2AuthSession that automatically refreshes tokens before expiration using the client_credentials grant.
1 parent 17ae164 commit 7d352f5

24 files changed

Lines changed: 1100 additions & 109 deletions

‎.github/workflows/test.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,4 @@ jobs:
167167
meson compile -C builddir
168168
- name: Test Iceberg
169169
run: |
170-
meson test -C builddir --timeout-multiplier 0
170+
meson test -C builddir --timeout-multiplier 0 --print-errorlogs

‎src/iceberg/CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ set(ICEBERG_SOURCES
102102
update/update_snapshot_reference.cc
103103
update/update_sort_order.cc
104104
update/update_statistics.cc
105+
util/base64.cc
105106
util/bucket_util.cc
106107
util/content_file_util.cc
107108
util/conversions.cc

‎src/iceberg/catalog/rest/CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ set(ICEBERG_REST_SOURCES
2323
auth/auth_properties.cc
2424
auth/auth_session.cc
2525
auth/oauth2_util.cc
26+
auth/token_refresh_scheduler.cc
2627
catalog_properties.cc
2728
endpoint.cc
2829
error_handlers.cc

‎src/iceberg/catalog/rest/auth/auth_manager.cc‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
#include "iceberg/catalog/rest/auth/auth_properties.h"
2626
#include "iceberg/catalog/rest/auth/auth_session.h"
2727
#include "iceberg/catalog/rest/auth/oauth2_util.h"
28+
#include "iceberg/util/base64.h"
2829
#include "iceberg/util/macros.h"
29-
#include "iceberg/util/transform_util.h"
3030

3131
namespace iceberg::rest::auth {
3232

@@ -83,8 +83,7 @@ class BasicAuthManager : public AuthManager {
8383
"Missing required property '{}'", AuthProperties::kBasicPassword);
8484
std::string credential = username_it->second + ":" + password_it->second;
8585
return AuthSession::MakeDefault(
86-
{{std::string(kAuthorizationHeader),
87-
"Basic " + TransformUtil::Base64Encode(credential)}});
86+
{{std::string(kAuthorizationHeader), "Basic " + Base64::Encode(credential)}});
8887
}
8988
};
9089

@@ -130,7 +129,8 @@ class OAuth2Manager : public AuthManager {
130129
init_token_response_.reset();
131130
return AuthSession::MakeOAuth2(token_response, config.oauth2_server_uri(),
132131
config.client_id(), config.client_secret(),
133-
config.scope(), client);
132+
config.scope(), config.keep_refreshed(),
133+
config.optional_oauth_params(), client);
134134
}
135135

136136
// If token is provided, use it directly.
@@ -143,10 +143,10 @@ class OAuth2Manager : public AuthManager {
143143
auto base_session = AuthSession::MakeDefault(AuthHeaders(config.token()));
144144
OAuthTokenResponse token_response;
145145
ICEBERG_ASSIGN_OR_RAISE(token_response, FetchToken(client, *base_session, config));
146-
// TODO(lishuxu): should we directly pass config to the MakeOAuth2 call?
147146
return AuthSession::MakeOAuth2(token_response, config.oauth2_server_uri(),
148147
config.client_id(), config.client_secret(),
149-
config.scope(), client);
148+
config.scope(), config.keep_refreshed(),
149+
config.optional_oauth_params(), client);
150150
}
151151

152152
return AuthSession::MakeDefault({});

‎src/iceberg/catalog/rest/auth/auth_session.cc‎

Lines changed: 250 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,19 @@
1919

2020
#include "iceberg/catalog/rest/auth/auth_session.h"
2121

22+
#include <algorithm>
23+
#include <chrono>
24+
#include <condition_variable>
25+
#include <memory>
26+
#include <mutex>
27+
#include <shared_mutex>
2228
#include <utility>
2329

30+
#include "iceberg/catalog/rest/auth/auth_properties.h"
2431
#include "iceberg/catalog/rest/auth/oauth2_util.h"
32+
#include "iceberg/catalog/rest/auth/token_refresh_scheduler.h"
33+
#include "iceberg/catalog/rest/http_client.h"
34+
#include "iceberg/util/macros.h"
2535

2636
namespace iceberg::rest::auth {
2737

@@ -44,20 +54,253 @@ class DefaultAuthSession : public AuthSession {
4454
std::unordered_map<std::string, std::string> headers_;
4555
};
4656

57+
/// \brief OAuth2 session with automatic token refresh.
58+
class OAuth2AuthSession : public AuthSession,
59+
public std::enable_shared_from_this<OAuth2AuthSession> {
60+
public:
61+
struct Config {
62+
std::string token_endpoint;
63+
std::string client_id;
64+
std::string client_secret;
65+
std::string scope;
66+
std::unordered_map<std::string, std::string> optional_oauth_params;
67+
bool keep_refreshed;
68+
};
69+
70+
/// \brief Create an OAuth2 session and optionally schedule refresh.
71+
static Result<std::shared_ptr<OAuth2AuthSession>> Make(
72+
const OAuthTokenResponse& initial_token, Config config, HttpClient& client) {
73+
ICEBERG_ASSIGN_OR_RAISE(auto refresh_properties, MakeRefreshProperties(config));
74+
auto session = std::shared_ptr<OAuth2AuthSession>(
75+
new OAuth2AuthSession(std::move(config), std::move(refresh_properties), client));
76+
session->SetInitialToken(initial_token);
77+
return session;
78+
}
79+
80+
Status Authenticate(std::unordered_map<std::string, std::string>& headers) override {
81+
std::shared_lock lock(mutex_);
82+
for (const auto& [key, value] : headers_) {
83+
headers.try_emplace(key, value);
84+
}
85+
return {};
86+
}
87+
88+
Status Close() override { return CloseImpl(); }
89+
90+
~OAuth2AuthSession() override { std::ignore = CloseImpl(); }
91+
92+
private:
93+
OAuth2AuthSession(Config config, AuthProperties refresh_properties, HttpClient& client)
94+
: config_(std::move(config)),
95+
refresh_properties_(std::move(refresh_properties)),
96+
client_(client) {}
97+
98+
Status CloseImpl() {
99+
bool expected = false;
100+
if (!closed_.compare_exchange_strong(expected, true)) {
101+
return {}; // Already closed
102+
}
103+
TokenRefreshScheduler::Instance().Cancel(scheduled_task_id_.exchange(0));
104+
std::unique_lock lock(refresh_mutex_);
105+
refresh_cv_.wait(lock, [this] { return active_refresh_count_ == 0; });
106+
TokenRefreshScheduler::Instance().Cancel(scheduled_task_id_.exchange(0));
107+
return {};
108+
}
109+
110+
static Result<AuthProperties> MakeRefreshProperties(const Config& config) {
111+
std::unordered_map<std::string, std::string> properties =
112+
config.optional_oauth_params;
113+
properties[AuthProperties::kCredential.key()] =
114+
config.client_id.empty() ? config.client_secret
115+
: config.client_id + ":" + config.client_secret;
116+
properties[AuthProperties::kScope.key()] = config.scope;
117+
properties[AuthProperties::kOAuth2ServerUri.key()] = config.token_endpoint;
118+
119+
return AuthProperties::FromProperties(properties);
120+
}
121+
122+
class RefreshAttemptGuard {
123+
public:
124+
explicit RefreshAttemptGuard(OAuth2AuthSession& session) : session_(session) {
125+
std::lock_guard lock(session_.refresh_mutex_);
126+
++session_.active_refresh_count_;
127+
}
128+
129+
~RefreshAttemptGuard() {
130+
bool notify = false;
131+
{
132+
std::lock_guard lock(session_.refresh_mutex_);
133+
notify = --session_.active_refresh_count_ == 0;
134+
}
135+
if (notify) {
136+
session_.refresh_cv_.notify_all();
137+
}
138+
}
139+
140+
private:
141+
OAuth2AuthSession& session_;
142+
};
143+
144+
void SetInitialToken(const OAuthTokenResponse& token_response) {
145+
token_ = token_response.access_token;
146+
headers_ = {{std::string(kAuthorizationHeader), std::string(kBearerPrefix) + token_}};
147+
148+
// Determine expiration time
149+
if (token_response.expires_in_secs.has_value()) {
150+
expires_at_ = std::chrono::steady_clock::now() +
151+
std::chrono::seconds(*token_response.expires_in_secs);
152+
} else if (auto exp_ms = ExpiresAtMillis(token_); exp_ms.has_value()) {
153+
// Convert absolute epoch millis to steady_clock time_point
154+
auto now_sys = std::chrono::system_clock::now();
155+
auto now_steady = std::chrono::steady_clock::now();
156+
auto exp_sys =
157+
std::chrono::system_clock::time_point(std::chrono::milliseconds(*exp_ms));
158+
expires_at_ = now_steady + (exp_sys - now_sys);
159+
}
160+
161+
if (config_.keep_refreshed &&
162+
expires_at_ != std::chrono::steady_clock::time_point{}) {
163+
ScheduleRefresh();
164+
}
165+
}
166+
167+
void DoRefresh() { DoRefreshAttempt(0, std::chrono::milliseconds(200)); }
168+
169+
/// \brief Single refresh attempt. On failure, schedules a retry via the
170+
/// scheduler (non-blocking) instead of sleeping on the worker thread.
171+
void DoRefreshAttempt(int attempt, std::chrono::milliseconds backoff) {
172+
static constexpr int kMaxRetries = 5;
173+
static constexpr auto kMaxBackoff = std::chrono::milliseconds(10'000);
174+
175+
RefreshAttemptGuard guard(*this);
176+
if (closed_.load()) return;
177+
178+
// Use an empty session for the refresh request (no auth headers —
179+
// avoids circular dependency of using an expired token to refresh itself)
180+
auto empty_session = AuthSession::MakeDefault({});
181+
182+
auto result = FetchToken(client_, *empty_session, refresh_properties_);
183+
if (result.has_value()) {
184+
auto& response = result.value();
185+
{
186+
std::unique_lock lock(mutex_);
187+
token_ = response.access_token;
188+
headers_ = {
189+
{std::string(kAuthorizationHeader), std::string(kBearerPrefix) + token_}};
190+
191+
// Reset before deriving new expiry
192+
expires_at_ = std::chrono::steady_clock::time_point{};
193+
194+
if (response.expires_in_secs.has_value()) {
195+
expires_at_ = std::chrono::steady_clock::now() +
196+
std::chrono::seconds(*response.expires_in_secs);
197+
} else if (auto exp_ms = ExpiresAtMillis(token_); exp_ms.has_value()) {
198+
auto now_sys = std::chrono::system_clock::now();
199+
auto now_steady = std::chrono::steady_clock::now();
200+
auto exp_sys =
201+
std::chrono::system_clock::time_point(std::chrono::milliseconds(*exp_ms));
202+
expires_at_ = now_steady + (exp_sys - now_sys);
203+
}
204+
}
205+
// Note: ScheduleRefresh must be called outside the lock.
206+
ScheduleRefresh();
207+
return; // Success
208+
}
209+
210+
// Schedule retry with exponential backoff (non-blocking)
211+
if (attempt + 1 < kMaxRetries && !closed_.load()) {
212+
auto next_backoff =
213+
std::min(std::chrono::duration_cast<std::chrono::milliseconds>(backoff * 2),
214+
kMaxBackoff);
215+
std::weak_ptr<OAuth2AuthSession> weak_self = shared_from_this();
216+
auto retry_id = TokenRefreshScheduler::Instance().Schedule(
217+
backoff,
218+
[weak_self = std::move(weak_self), next_attempt = attempt + 1, next_backoff] {
219+
if (auto self = weak_self.lock()) {
220+
self->DoRefreshAttempt(next_attempt, next_backoff);
221+
}
222+
});
223+
scheduled_task_id_.store(retry_id);
224+
}
225+
// All retries exhausted — stop refreshing silently.
226+
// Next request will use the expired token; server returns 401.
227+
}
228+
229+
/// \brief Schedule the next token refresh based on expiration time.
230+
///
231+
/// Must be called outside any lock on mutex_ (CalculateRefreshDelay
232+
/// acquires shared_lock internally).
233+
void ScheduleRefresh() {
234+
if (!config_.keep_refreshed || closed_.load()) return;
235+
236+
auto delay = CalculateRefreshDelay();
237+
if (delay < std::chrono::milliseconds::zero()) return;
238+
239+
std::weak_ptr<OAuth2AuthSession> weak_self = shared_from_this();
240+
auto new_id = TokenRefreshScheduler::Instance().Schedule(
241+
delay, [weak_self = std::move(weak_self)] {
242+
if (auto self = weak_self.lock()) {
243+
self->DoRefresh();
244+
}
245+
});
246+
scheduled_task_id_.store(new_id);
247+
}
248+
249+
std::chrono::milliseconds CalculateRefreshDelay() const {
250+
std::shared_lock lock(mutex_);
251+
auto now = std::chrono::steady_clock::now();
252+
if (expires_at_ == std::chrono::steady_clock::time_point{}) {
253+
return std::chrono::milliseconds(-1);
254+
}
255+
if (expires_at_ <= now) return std::chrono::milliseconds::zero();
256+
257+
auto expires_in =
258+
std::chrono::duration_cast<std::chrono::milliseconds>(expires_at_ - now);
259+
// Refresh window: 10% of remaining time, capped at 5 minutes
260+
auto refresh_window = std::min(expires_in / 10, std::chrono::milliseconds(300'000));
261+
auto wait_time = expires_in - refresh_window;
262+
return std::max(wait_time, std::chrono::milliseconds(10));
263+
}
264+
265+
mutable std::shared_mutex mutex_; // protects token_, headers_, expires_at_
266+
std::string token_;
267+
std::unordered_map<std::string, std::string> headers_;
268+
std::chrono::steady_clock::time_point expires_at_{};
269+
270+
Config config_;
271+
AuthProperties refresh_properties_;
272+
HttpClient& client_; // It should outlive the session
273+
std::atomic<uint64_t> scheduled_task_id_{0};
274+
std::atomic<bool> closed_{false};
275+
std::mutex refresh_mutex_;
276+
std::condition_variable refresh_cv_;
277+
int active_refresh_count_ = 0;
278+
};
279+
47280
} // namespace
48281

49282
std::shared_ptr<AuthSession> AuthSession::MakeDefault(
50283
std::unordered_map<std::string, std::string> headers) {
51284
return std::make_shared<DefaultAuthSession>(std::move(headers));
52285
}
53286

54-
std::shared_ptr<AuthSession> AuthSession::MakeOAuth2(
55-
const OAuthTokenResponse& initial_token, const std::string& /*token_endpoint*/,
56-
const std::string& /*client_id*/, const std::string& /*client_secret*/,
57-
const std::string& /*scope*/, HttpClient& /*client*/) {
58-
// TODO(lishuxu): Create OAuth2AuthSession with auto-refresh support.
59-
return MakeDefault({{std::string(kAuthorizationHeader),
60-
std::string(kBearerPrefix) + initial_token.access_token}});
287+
Result<std::shared_ptr<AuthSession>> AuthSession::MakeOAuth2(
288+
const OAuthTokenResponse& initial_token, const std::string& token_endpoint,
289+
const std::string& client_id, const std::string& client_secret,
290+
const std::string& scope, bool keep_refreshed,
291+
const std::unordered_map<std::string, std::string>& optional_oauth_params,
292+
HttpClient& client) {
293+
OAuth2AuthSession::Config config{
294+
.token_endpoint = token_endpoint,
295+
.client_id = client_id,
296+
.client_secret = client_secret,
297+
.scope = scope,
298+
.optional_oauth_params = optional_oauth_params,
299+
.keep_refreshed = keep_refreshed,
300+
};
301+
ICEBERG_ASSIGN_OR_RAISE(
302+
auto session, OAuth2AuthSession::Make(initial_token, std::move(config), client));
303+
return std::static_pointer_cast<AuthSession>(std::move(session));
61304
}
62305

63306
} // namespace iceberg::rest::auth

‎src/iceberg/catalog/rest/auth/auth_session.h‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -74,24 +74,27 @@ class ICEBERG_REST_EXPORT AuthSession {
7474

7575
/// \brief Create an OAuth2 session with automatic token refresh.
7676
///
77-
/// This factory method creates a session that holds an access token and
78-
/// optionally a refresh token. When Authenticate() is called and the token
79-
/// is expired, it transparently refreshes the token before setting the
80-
/// Authorization header.
77+
/// This factory method creates a session that holds an access token and,
78+
/// when keep_refreshed is enabled, schedules background refresh based on
79+
/// token expiration. Authenticate() uses the latest cached Authorization
80+
/// header and does not perform a synchronous token refresh.
8181
///
8282
/// \param initial_token The initial token response from FetchToken().
8383
/// \param token_endpoint Full URL of the OAuth2 token endpoint for refresh.
8484
/// \param client_id OAuth2 client ID for refresh requests.
8585
/// \param client_secret OAuth2 client secret for re-fetch if refresh fails.
8686
/// \param scope OAuth2 scope for refresh requests.
87-
/// \param client HTTP client for making refresh requests.
87+
/// \param keep_refreshed Whether to schedule automatic token refresh.
88+
/// \param optional_oauth_params Optional OAuth params (audience, resource) for refresh.
89+
/// \param client HTTP client for making refresh requests. The caller owns the
90+
/// client and must keep it alive until the session is closed.
8891
/// \return A new session that manages token lifecycle automatically.
89-
static std::shared_ptr<AuthSession> MakeOAuth2(const OAuthTokenResponse& initial_token,
90-
const std::string& token_endpoint,
91-
const std::string& client_id,
92-
const std::string& client_secret,
93-
const std::string& scope,
94-
HttpClient& client);
92+
static Result<std::shared_ptr<AuthSession>> MakeOAuth2(
93+
const OAuthTokenResponse& initial_token, const std::string& token_endpoint,
94+
const std::string& client_id, const std::string& client_secret,
95+
const std::string& scope, bool keep_refreshed,
96+
const std::unordered_map<std::string, std::string>& optional_oauth_params,
97+
HttpClient& client);
9598
};
9699

97100
} // namespace iceberg::rest::auth

0 commit comments

Comments
 (0)