Skip to content

Commit 2377328

Browse files
authored
feat(rest): support OAuth2 token exchange sessions (#867)
Add RFC 8693 token exchange support, including OAuth2 endpoint handling and response parsing. Create contextual and table child sessions from direct tokens, credentials, or typed tokens. Keep OAuth2 metadata in one synchronized session snapshot. Use shared `HttpClient` ownership so refresh tasks can safely keep the client alive. Align token expiry with Java by preferring JWT `exp` and otherwise using the request start time plus `expires_in`. Move OAuth2 helpers into `OAuth2Util` and update unit and integration tests. Leave catalog token refresh, token exchange during refresh, and child session caching as follow-up work.
1 parent 268883e commit 2377328

18 files changed

Lines changed: 1129 additions & 331 deletions

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

Lines changed: 192 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,67 @@
1919

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

22+
#include <array>
23+
#include <chrono>
2224
#include <optional>
25+
#include <string_view>
26+
#include <utility>
2327

2428
#include "iceberg/catalog/rest/auth/auth_manager_internal.h"
2529
#include "iceberg/catalog/rest/auth/auth_properties.h"
2630
#include "iceberg/catalog/rest/auth/auth_session.h"
31+
#include "iceberg/catalog/rest/auth/auth_session_internal.h"
2732
#include "iceberg/catalog/rest/auth/oauth2_util.h"
33+
#include "iceberg/catalog/session_context.h"
2834
#include "iceberg/util/base64.h"
2935
#include "iceberg/util/macros.h"
3036

3137
namespace iceberg::rest::auth {
3238

39+
namespace {
40+
41+
constexpr std::string_view kAuthorizationHeader = "Authorization";
42+
43+
const std::array<std::string_view, 5> kTokenPreferenceOrder = {
44+
AuthProperties::kIdTokenType, AuthProperties::kAccessTokenType,
45+
AuthProperties::kJwtTokenType, AuthProperties::kSaml2TokenType,
46+
AuthProperties::kSaml1TokenType,
47+
};
48+
49+
std::optional<std::pair<std::string, std::string>> FindPreferredTypedToken(
50+
const std::unordered_map<std::string, std::string>& credentials) {
51+
for (std::string_view token_type : kTokenPreferenceOrder) {
52+
auto token_it = credentials.find(std::string(token_type));
53+
if (token_it != credentials.end()) {
54+
return std::pair{token_it->first, token_it->second};
55+
}
56+
}
57+
return std::nullopt;
58+
}
59+
60+
std::unordered_map<std::string, std::string> FilterTableSessionProperties(
61+
const std::unordered_map<std::string, std::string>& properties) {
62+
std::unordered_map<std::string, std::string> filtered;
63+
if (auto token_it = properties.find(AuthProperties::kToken.key());
64+
token_it != properties.end()) {
65+
filtered.emplace(token_it->first, token_it->second);
66+
}
67+
for (std::string_view token_type : kTokenPreferenceOrder) {
68+
auto token_it = properties.find(std::string(token_type));
69+
if (token_it != properties.end()) {
70+
filtered.emplace(token_it->first, token_it->second);
71+
}
72+
}
73+
return filtered;
74+
}
75+
76+
} // namespace
77+
3378
Result<std::shared_ptr<AuthSession>> AuthManager::InitSession(
34-
HttpClient& init_client,
79+
std::shared_ptr<HttpClient> init_client,
3580
const std::unordered_map<std::string, std::string>& properties) {
3681
// By default, use the catalog session for initialization
37-
return CatalogSession(init_client, properties);
82+
return CatalogSession(std::move(init_client), properties);
3883
}
3984

4085
Result<std::shared_ptr<AuthSession>> AuthManager::ContextualSession(
@@ -55,7 +100,7 @@ Result<std::shared_ptr<AuthSession>> AuthManager::TableSession(
55100
class NoopAuthManager : public AuthManager {
56101
public:
57102
Result<std::shared_ptr<AuthSession>> CatalogSession(
58-
[[maybe_unused]] HttpClient& client,
103+
[[maybe_unused]] std::shared_ptr<HttpClient> client,
59104
[[maybe_unused]] const std::unordered_map<std::string, std::string>& properties)
60105
override {
61106
return AuthSession::MakeDefault({});
@@ -72,7 +117,7 @@ Result<std::unique_ptr<AuthManager>> MakeNoopAuthManager(
72117
class BasicAuthManager : public AuthManager {
73118
public:
74119
Result<std::shared_ptr<AuthSession>> CatalogSession(
75-
[[maybe_unused]] HttpClient& client,
120+
[[maybe_unused]] std::shared_ptr<HttpClient> client,
76121
const std::unordered_map<std::string, std::string>& properties) override {
77122
auto username_it = properties.find(AuthProperties::kBasicUsername);
78123
ICEBERG_PRECHECK(username_it != properties.end() && !username_it->second.empty(),
@@ -96,67 +141,184 @@ Result<std::unique_ptr<AuthManager>> MakeBasicAuthManager(
96141
class OAuth2Manager : public AuthManager {
97142
public:
98143
Result<std::shared_ptr<AuthSession>> InitSession(
99-
HttpClient& init_client,
144+
std::shared_ptr<HttpClient> init_client,
100145
const std::unordered_map<std::string, std::string>& properties) override {
146+
ICEBERG_PRECHECK(init_client != nullptr,
147+
"OAuth2 initialization HTTP client must not be null");
101148
ICEBERG_ASSIGN_OR_RAISE(auto config, AuthProperties::FromProperties(properties));
102149
// No token refresh during init (short-lived session).
103150
config.Set(AuthProperties::kKeepRefreshed, false);
104151

105152
// Credential takes priority: fetch a fresh token for the config request.
106153
if (!config.credential().empty()) {
107-
auto init_session = AuthSession::MakeDefault(AuthHeaders(config.token()));
108-
ICEBERG_ASSIGN_OR_RAISE(init_token_response_,
109-
FetchToken(init_client, *init_session, config));
110-
return AuthSession::MakeDefault(AuthHeaders(init_token_response_->access_token));
154+
auto init_session =
155+
AuthSession::MakeDefault(OAuth2Util::AuthHeaders(config.token()));
156+
start_time_ = std::chrono::steady_clock::now();
157+
ICEBERG_ASSIGN_OR_RAISE(
158+
auth_response_, OAuth2Util::FetchToken(*init_client, *init_session, config));
159+
// TODO(lishuxu): Match Java OAuth2Util.AuthSession.fromTokenResponse here.
160+
return AuthSession::MakeDefault(
161+
OAuth2Util::AuthHeaders(auth_response_->access_token));
111162
}
112163

113164
if (!config.token().empty()) {
114-
return AuthSession::MakeDefault(AuthHeaders(config.token()));
165+
// TODO(lishuxu): Match Java OAuth2Util.AuthSession.fromAccessToken here.
166+
return AuthSession::MakeDefault(OAuth2Util::AuthHeaders(config.token()));
115167
}
116168

117169
return AuthSession::MakeDefault({});
118170
}
119171

120172
Result<std::shared_ptr<AuthSession>> CatalogSession(
121-
HttpClient& client,
173+
std::shared_ptr<HttpClient> shared_client,
122174
const std::unordered_map<std::string, std::string>& properties) override {
123175
ICEBERG_ASSIGN_OR_RAISE(auto config, AuthProperties::FromProperties(properties));
124-
125-
// Reuse token from init phase.
126-
if (init_token_response_.has_value()) {
127-
auto token_response = std::move(*init_token_response_);
128-
init_token_response_.reset();
129-
return AuthSession::MakeOAuth2(token_response, config.oauth2_server_uri(),
130-
config.client_id(), config.client_secret(),
131-
config.scope(), config.keep_refreshed(),
132-
config.optional_oauth_params(), client);
176+
ICEBERG_PRECHECK(shared_client != nullptr,
177+
"OAuth2 catalog session HTTP client must not be null");
178+
refresh_client_ = std::move(shared_client);
179+
// Reuse the token response and start time from the init phase.
180+
if (auth_response_.has_value()) {
181+
return internal::MakeOAuth2Session(
182+
*auth_response_, config.oauth2_server_uri(), config.client_id(),
183+
config.client_secret(), config.scope(), config.keep_refreshed(),
184+
config.optional_oauth_params(), refresh_client_, start_time_);
133185
}
134186

135-
// If token is provided, use it directly.
187+
// TODO(lishuxu): Honor token-refresh-enabled for catalog bearer tokens, matching
188+
// Java. If token is provided, use it directly.
136189
if (!config.token().empty()) {
137-
return AuthSession::MakeDefault(AuthHeaders(config.token()));
190+
OAuthTokenResponse token_response{
191+
.access_token = config.token(),
192+
.token_type = "bearer",
193+
.issued_token_type = AuthProperties::kAccessTokenType,
194+
};
195+
return AuthSession::MakeOAuth2(token_response, config.oauth2_server_uri(),
196+
config.client_id(), config.client_secret(),
197+
config.scope(), /*keep_refreshed=*/false,
198+
config.optional_oauth_params(), refresh_client_);
138199
}
139200

140201
// Fetch a new token using client_credentials grant.
141202
if (!config.credential().empty()) {
142-
auto base_session = AuthSession::MakeDefault(AuthHeaders(config.token()));
203+
auto base_session =
204+
AuthSession::MakeDefault(OAuth2Util::AuthHeaders(config.token()));
143205
OAuthTokenResponse token_response;
144-
ICEBERG_ASSIGN_OR_RAISE(token_response, FetchToken(client, *base_session, config));
206+
ICEBERG_ASSIGN_OR_RAISE(
207+
token_response,
208+
OAuth2Util::FetchToken(*refresh_client_, *base_session, config));
145209
return AuthSession::MakeOAuth2(token_response, config.oauth2_server_uri(),
146210
config.client_id(), config.client_secret(),
147211
config.scope(), config.keep_refreshed(),
148-
config.optional_oauth_params(), client);
212+
config.optional_oauth_params(), refresh_client_);
149213
}
150214

151-
return AuthSession::MakeDefault({});
215+
return MakeSession(AccessTokenResponse(""), config, /*keep_refreshed=*/false);
152216
}
153217

154-
// TODO(lishuxu): Override TableSession() for token exchange (RFC 8693).
155-
// TODO(lishuxu): Override ContextualSession() for per-context exchange.
218+
Result<std::shared_ptr<AuthSession>> ContextualSession(
219+
const SessionContext& context, std::shared_ptr<AuthSession> parent) override {
220+
// TODO(lishuxu): Add child-session caching and refresh, matching Java
221+
// AuthSessionCache.
222+
return MaybeCreateChildSession(context.credentials, /*allow_credential=*/true,
223+
std::move(parent));
224+
}
225+
226+
Result<std::shared_ptr<AuthSession>> TableSession(
227+
[[maybe_unused]] const TableIdentifier& table,
228+
const std::unordered_map<std::string, std::string>& properties,
229+
std::shared_ptr<AuthSession> parent) override {
230+
return MaybeCreateChildSession(FilterTableSessionProperties(properties),
231+
/*allow_credential=*/false, std::move(parent));
232+
}
233+
234+
Status Close() override {
235+
refresh_client_.reset();
236+
return {};
237+
}
156238

157239
private:
158-
/// Cached token from InitSession
159-
std::optional<OAuthTokenResponse> init_token_response_;
240+
static OAuthTokenResponse AccessTokenResponse(std::string token) {
241+
return {
242+
.access_token = std::move(token),
243+
.token_type = "bearer",
244+
.issued_token_type = AuthProperties::kAccessTokenType,
245+
};
246+
}
247+
248+
static Result<AuthProperties> ChildConfig(const OAuth2SessionInfo& parent_info,
249+
const std::string& credential) {
250+
auto properties = parent_info.optional_oauth_params;
251+
properties[AuthProperties::kCredential.key()] = credential;
252+
properties[AuthProperties::kScope.key()] = parent_info.scope;
253+
properties[AuthProperties::kOAuth2ServerUri.key()] = parent_info.oauth2_server_uri;
254+
return AuthProperties::FromProperties(properties);
255+
}
256+
257+
Result<std::shared_ptr<AuthSession>> MakeSession(
258+
const OAuthTokenResponse& token_response, const AuthProperties& config,
259+
bool keep_refreshed) const {
260+
ICEBERG_PRECHECK(refresh_client_ != nullptr,
261+
"OAuth2 catalog session must be initialized before child sessions");
262+
return AuthSession::MakeOAuth2(token_response, config.oauth2_server_uri(),
263+
config.client_id(), config.client_secret(),
264+
config.scope(), keep_refreshed,
265+
config.optional_oauth_params(), refresh_client_);
266+
}
267+
268+
Result<std::shared_ptr<AuthSession>> MaybeCreateChildSession(
269+
const std::unordered_map<std::string, std::string>& credentials,
270+
bool allow_credential, std::shared_ptr<AuthSession> parent) {
271+
auto token_it = credentials.find(AuthProperties::kToken.key());
272+
auto credential_it = credentials.find(AuthProperties::kCredential.key());
273+
auto typed_token = FindPreferredTypedToken(credentials);
274+
if (token_it == credentials.end() &&
275+
(!allow_credential || credential_it == credentials.end()) &&
276+
!typed_token.has_value()) {
277+
return parent;
278+
}
279+
280+
ICEBERG_PRECHECK(refresh_client_ != nullptr,
281+
"OAuth2 catalog session must be initialized before child sessions");
282+
auto parent_info = parent->OAuth2Info();
283+
ICEBERG_PRECHECK(parent_info.has_value(),
284+
"OAuth2 child session requires OAuth2 parent metadata");
285+
286+
if (token_it != credentials.end()) {
287+
ICEBERG_ASSIGN_OR_RAISE(auto config,
288+
ChildConfig(*parent_info, parent_info->credential));
289+
return MakeSession(AccessTokenResponse(token_it->second), config,
290+
/*keep_refreshed=*/false);
291+
}
292+
293+
if (allow_credential && credential_it != credentials.end()) {
294+
ICEBERG_ASSIGN_OR_RAISE(auto config,
295+
ChildConfig(*parent_info, credential_it->second));
296+
ICEBERG_ASSIGN_OR_RAISE(auto response,
297+
OAuth2Util::FetchToken(*refresh_client_, *parent, config));
298+
return MakeSession(response, config, /*keep_refreshed=*/false);
299+
}
300+
301+
std::optional<std::string> actor_token;
302+
std::optional<std::string> actor_token_type;
303+
if (!parent_info->token.empty()) {
304+
actor_token = parent_info->token;
305+
actor_token_type = parent_info->issued_token_type;
306+
}
307+
ICEBERG_ASSIGN_OR_RAISE(
308+
auto response,
309+
OAuth2Util::ExchangeToken(*refresh_client_, *parent, {}, typed_token->second,
310+
typed_token->first, actor_token, actor_token_type,
311+
parent_info->scope, parent_info->oauth2_server_uri,
312+
parent_info->optional_oauth_params));
313+
ICEBERG_ASSIGN_OR_RAISE(auto config,
314+
ChildConfig(*parent_info, parent_info->credential));
315+
return MakeSession(response, config, /*keep_refreshed=*/false);
316+
}
317+
318+
/// Token response and start time captured by InitSession.
319+
std::optional<OAuthTokenResponse> auth_response_;
320+
std::optional<std::chrono::steady_clock::time_point> start_time_;
321+
std::shared_ptr<HttpClient> refresh_client_;
160322
};
161323

162324
Result<std::unique_ptr<AuthManager>> MakeOAuth2Manager(

src/iceberg/catalog/rest/auth/auth_manager.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class ICEBERG_REST_EXPORT AuthManager {
4848
/// \param properties Client configuration supplied by the catalog.
4949
/// \return Session for initialization or an error if credentials cannot be acquired.
5050
virtual Result<std::shared_ptr<AuthSession>> InitSession(
51-
HttpClient& init_client,
51+
std::shared_ptr<HttpClient> init_client,
5252
const std::unordered_map<std::string, std::string>& properties);
5353

5454
/// \brief Create the long-lived catalog session that acts as the parent session.
@@ -62,7 +62,7 @@ class ICEBERG_REST_EXPORT AuthManager {
6262
/// \return Session for catalog operations or an error if authentication cannot be set
6363
/// up.
6464
virtual Result<std::shared_ptr<AuthSession>> CatalogSession(
65-
HttpClient& shared_client,
65+
std::shared_ptr<HttpClient> shared_client,
6666
const std::unordered_map<std::string, std::string>& properties) = 0;
6767

6868
/// \brief Create or reuse a session for a specific context.

src/iceberg/catalog/rest/auth/auth_properties.cc

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include <utility>
2323

2424
#include "iceberg/catalog/rest/catalog_properties.h"
25+
#include "iceberg/catalog/rest/rest_util.h"
2526

2627
namespace iceberg::rest::auth {
2728

@@ -35,6 +36,31 @@ std::pair<std::string, std::string> ParseCredential(const std::string& credentia
3536
return {credential.substr(0, colon_pos), credential.substr(colon_pos + 1)};
3637
}
3738

39+
Result<std::string> ResolveOAuth2ServerUri(
40+
const std::unordered_map<std::string, std::string>& properties) {
41+
auto endpoint_it = properties.find(AuthProperties::kOAuth2ServerUri.key());
42+
std::string endpoint = endpoint_it == properties.end()
43+
? AuthProperties::kOAuth2ServerUri.value()
44+
: endpoint_it->second;
45+
46+
if (endpoint.starts_with("http://") || endpoint.starts_with("https://")) {
47+
return endpoint;
48+
}
49+
if (endpoint.empty()) {
50+
return endpoint;
51+
}
52+
auto uri_it = properties.find(RestCatalogProperties::kUri.key());
53+
if (uri_it == properties.end() || uri_it->second.empty()) {
54+
return endpoint;
55+
}
56+
57+
auto base_uri = std::string(TrimTrailingSlash(uri_it->second));
58+
if (endpoint.starts_with('/')) {
59+
return base_uri + endpoint;
60+
}
61+
return base_uri + "/" + endpoint;
62+
}
63+
3864
} // namespace
3965

4066
std::unordered_map<std::string, std::string> AuthProperties::optional_oauth_params()
@@ -61,19 +87,8 @@ Result<AuthProperties> AuthProperties::FromProperties(
6187
config.client_secret_ = std::move(secret);
6288
}
6389

64-
// Resolve token endpoint: if not explicitly set, derive from catalog URI
65-
if (properties.find(kOAuth2ServerUri.key()) == properties.end() ||
66-
properties.at(kOAuth2ServerUri.key()).empty()) {
67-
auto uri_it = properties.find(RestCatalogProperties::kUri.key());
68-
if (uri_it != properties.end() && !uri_it->second.empty()) {
69-
std::string_view base = uri_it->second;
70-
while (!base.empty() && base.back() == '/') {
71-
base.remove_suffix(1);
72-
}
73-
config.Set(kOAuth2ServerUri,
74-
std::string(base) + "/" + std::string(kOAuth2ServerUri.value()));
75-
}
76-
}
90+
ICEBERG_ASSIGN_OR_RAISE(auto oauth2_server_uri, ResolveOAuth2ServerUri(properties));
91+
config.Set(kOAuth2ServerUri, std::move(oauth2_server_uri));
7792

7893
// TODO(lishuxu): Parse JWT exp claim from token to set expires_at_millis_.
7994

src/iceberg/catalog/rest/auth/auth_properties.h

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@ class ICEBERG_REST_EXPORT AuthProperties : public ConfigBase<AuthProperties> {
8282
inline static Entry<std::string> kAudience{"audience", ""};
8383
inline static Entry<std::string> kResource{"resource", ""};
8484

85+
// ---- OAuth2 token type constants ----
86+
87+
inline static const std::string kAccessTokenType =
88+
"urn:ietf:params:oauth:token-type:access_token";
89+
inline static const std::string kRefreshTokenType =
90+
"urn:ietf:params:oauth:token-type:refresh_token";
91+
inline static const std::string kIdTokenType =
92+
"urn:ietf:params:oauth:token-type:id_token";
93+
inline static const std::string kSaml1TokenType =
94+
"urn:ietf:params:oauth:token-type:saml1";
95+
inline static const std::string kSaml2TokenType =
96+
"urn:ietf:params:oauth:token-type:saml2";
97+
inline static const std::string kJwtTokenType = "urn:ietf:params:oauth:token-type:jwt";
98+
8599
/// \brief Build an AuthProperties from a properties map.
86100
static Result<AuthProperties> FromProperties(
87101
const std::unordered_map<std::string, std::string>& properties);

0 commit comments

Comments
 (0)