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
2636namespace 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
49282std::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
0 commit comments