From 79e81ae42df3b7365254abf8891505b3529574dc Mon Sep 17 00:00:00 2001 From: "shuxu.li" Date: Thu, 26 Feb 2026 20:00:07 +0800 Subject: [PATCH 1/4] feat: Implement OAuth2 authentication for REST catalog --- src/iceberg/catalog/rest/CMakeLists.txt | 1 + src/iceberg/catalog/rest/auth/auth_manager.cc | 137 ++++++++++++++++++ .../catalog/rest/auth/auth_manager_internal.h | 5 + .../catalog/rest/auth/auth_managers.cc | 1 + .../catalog/rest/auth/auth_properties.h | 2 + src/iceberg/catalog/rest/auth/auth_session.cc | 15 ++ src/iceberg/catalog/rest/auth/auth_session.h | 22 +++ src/iceberg/catalog/rest/auth/oauth2_util.cc | 115 +++++++++++++++ src/iceberg/catalog/rest/auth/oauth2_util.h | 109 ++++++++++++++ src/iceberg/catalog/rest/http_client.cc | 2 +- src/iceberg/catalog/rest/meson.build | 2 + src/iceberg/catalog/rest/rest_catalog.cc | 13 +- src/iceberg/catalog/rest/type_fwd.h | 1 + src/iceberg/test/auth_manager_test.cc | 106 ++++++++++++++ 14 files changed, 529 insertions(+), 2 deletions(-) create mode 100644 src/iceberg/catalog/rest/auth/oauth2_util.cc create mode 100644 src/iceberg/catalog/rest/auth/oauth2_util.h diff --git a/src/iceberg/catalog/rest/CMakeLists.txt b/src/iceberg/catalog/rest/CMakeLists.txt index 1da47d680..2fe8d21bf 100644 --- a/src/iceberg/catalog/rest/CMakeLists.txt +++ b/src/iceberg/catalog/rest/CMakeLists.txt @@ -21,6 +21,7 @@ set(ICEBERG_REST_SOURCES auth/auth_manager.cc auth/auth_managers.cc auth/auth_session.cc + auth/oauth2_util.cc catalog_properties.cc endpoint.cc error_handlers.cc diff --git a/src/iceberg/catalog/rest/auth/auth_manager.cc b/src/iceberg/catalog/rest/auth/auth_manager.cc index 14946aef6..8c9b25672 100644 --- a/src/iceberg/catalog/rest/auth/auth_manager.cc +++ b/src/iceberg/catalog/rest/auth/auth_manager.cc @@ -19,9 +19,14 @@ #include "iceberg/catalog/rest/auth/auth_manager.h" +#include + #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/oauth2_util.h" +#include "iceberg/catalog/rest/catalog_properties.h" +#include "iceberg/catalog/rest/http_client.h" #include "iceberg/util/macros.h" #include "iceberg/util/transform_util.h" @@ -90,4 +95,136 @@ Result> MakeBasicAuthManager( return std::make_unique(); } +/// \brief OAuth2 authentication manager. +/// +/// Two-phase init: InitSession fetches and caches a token for the config request; +/// CatalogSession reuses the cached token and enables refresh. +class OAuth2AuthManager : public AuthManager { + public: + Result> InitSession( + HttpClient& init_client, + const std::unordered_map& properties) override { + // Credential takes priority: fetch a fresh token for the config request. + auto credential_it = properties.find(AuthProperties::kOAuth2Credential); + if (credential_it != properties.end() && !credential_it->second.empty()) { + ICEBERG_ASSIGN_OR_RAISE(auto ctx, ParseOAuth2Context(properties)); + auto noop_session = AuthSession::MakeDefault({}); + ICEBERG_ASSIGN_OR_RAISE(init_token_response_, + FetchToken(init_client, ctx.token_endpoint, ctx.client_id, + ctx.client_secret, ctx.scope, *noop_session)); + return AuthSession::MakeDefault( + {{"Authorization", "Bearer " + init_token_response_->access_token}}); + } + + auto token_it = properties.find(AuthProperties::kOAuth2Token); + if (token_it != properties.end() && !token_it->second.empty()) { + return AuthSession::MakeDefault({{"Authorization", "Bearer " + token_it->second}}); + } + + return AuthSession::MakeDefault({}); + } + + Result> CatalogSession( + HttpClient& client, + const std::unordered_map& properties) override { + // Reuse the token fetched during InitSession. + if (init_token_response_.has_value()) { + auto token_response = std::move(*init_token_response_); + init_token_response_.reset(); + ICEBERG_ASSIGN_OR_RAISE(auto ctx, ParseOAuth2Context(properties)); + return AuthSession::MakeOAuth2(token_response, ctx.token_endpoint, ctx.client_id, + ctx.client_secret, ctx.scope, client); + } + + // If both token and credential are provided, prefer the token. + auto token_it = properties.find(AuthProperties::kOAuth2Token); + if (token_it != properties.end() && !token_it->second.empty()) { + return AuthSession::MakeDefault({{"Authorization", "Bearer " + token_it->second}}); + } + + // Fetch a new token using client_credentials grant. + auto credential_it = properties.find(AuthProperties::kOAuth2Credential); + if (credential_it != properties.end() && !credential_it->second.empty()) { + ICEBERG_ASSIGN_OR_RAISE(auto ctx, ParseOAuth2Context(properties)); + auto noop_session = AuthSession::MakeDefault({}); + OAuthTokenResponse token_response; + ICEBERG_ASSIGN_OR_RAISE(token_response, + FetchToken(client, ctx.token_endpoint, ctx.client_id, + ctx.client_secret, ctx.scope, *noop_session)); + return AuthSession::MakeOAuth2(token_response, ctx.token_endpoint, ctx.client_id, + ctx.client_secret, ctx.scope, client); + } + + return AuthSession::MakeDefault({}); + } + + // TODO(lishuxu): Override TableSession() to support token exchange (RFC 8693). + // TODO(lishuxu): Override ContextualSession() to support per-context token exchange. + + private: + struct OAuth2Context { + std::string client_id; + std::string client_secret; + std::string token_endpoint; + std::string scope; + }; + + /// \brief Parse credential, token endpoint, and scope from properties. + static Result ParseOAuth2Context( + const std::unordered_map& properties) { + OAuth2Context ctx; + + auto credential_it = properties.find(AuthProperties::kOAuth2Credential); + if (credential_it == properties.end() || credential_it->second.empty()) { + return InvalidArgument("OAuth2 authentication requires '{}' property", + AuthProperties::kOAuth2Credential); + } + const auto& credential = credential_it->second; + auto colon_pos = credential.find(':'); + if (colon_pos == std::string::npos) { + return InvalidArgument( + "Invalid OAuth2 credential format: expected 'client_id:client_secret'"); + } + ctx.client_id = credential.substr(0, colon_pos); + ctx.client_secret = credential.substr(colon_pos + 1); + + auto uri_it = properties.find(AuthProperties::kOAuth2ServerUri); + if (uri_it != properties.end() && !uri_it->second.empty()) { + ctx.token_endpoint = uri_it->second; + } else { + // {uri}/v1/oauth/tokens. + auto catalog_uri_it = properties.find(RestCatalogProperties::kUri.key()); + if (catalog_uri_it == properties.end() || catalog_uri_it->second.empty()) { + return InvalidArgument( + "OAuth2 authentication requires '{}' or '{}' property to determine " + "token endpoint", + AuthProperties::kOAuth2ServerUri, RestCatalogProperties::kUri.key()); + } + std::string_view base = catalog_uri_it->second; + while (!base.empty() && base.back() == '/') { + base.remove_suffix(1); + } + ctx.token_endpoint = + std::string(base) + "/" + std::string(AuthProperties::kOAuth2DefaultTokenPath); + } + + ctx.scope = AuthProperties::kOAuth2DefaultScope; + auto scope_it = properties.find(AuthProperties::kOAuth2Scope); + if (scope_it != properties.end() && !scope_it->second.empty()) { + ctx.scope = scope_it->second; + } + + return ctx; + } + + /// Cached token from InitSession + std::optional init_token_response_; +}; + +Result> MakeOAuth2AuthManager( + [[maybe_unused]] std::string_view name, + [[maybe_unused]] const std::unordered_map& properties) { + return std::make_unique(); +} + } // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/auth/auth_manager_internal.h b/src/iceberg/catalog/rest/auth/auth_manager_internal.h index 96e452390..e6f2b7bff 100644 --- a/src/iceberg/catalog/rest/auth/auth_manager_internal.h +++ b/src/iceberg/catalog/rest/auth/auth_manager_internal.h @@ -42,4 +42,9 @@ Result> MakeBasicAuthManager( std::string_view name, const std::unordered_map& properties); +/// \brief Create an OAuth2 authentication manager. +Result> MakeOAuth2AuthManager( + std::string_view name, + const std::unordered_map& properties); + } // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/auth/auth_managers.cc b/src/iceberg/catalog/rest/auth/auth_managers.cc index d0bf24844..1410e1f87 100644 --- a/src/iceberg/catalog/rest/auth/auth_managers.cc +++ b/src/iceberg/catalog/rest/auth/auth_managers.cc @@ -65,6 +65,7 @@ AuthManagerRegistry CreateDefaultRegistry() { return { {AuthProperties::kAuthTypeNone, MakeNoopAuthManager}, {AuthProperties::kAuthTypeBasic, MakeBasicAuthManager}, + {AuthProperties::kAuthTypeOAuth2, MakeOAuth2AuthManager}, }; } diff --git a/src/iceberg/catalog/rest/auth/auth_properties.h b/src/iceberg/catalog/rest/auth/auth_properties.h index e14b7fcf7..55e081ae6 100644 --- a/src/iceberg/catalog/rest/auth/auth_properties.h +++ b/src/iceberg/catalog/rest/auth/auth_properties.h @@ -60,6 +60,8 @@ struct AuthProperties { inline static const std::string kOAuth2TokenRefreshEnabled = "token-refresh-enabled"; /// \brief Default OAuth2 scope for catalog operations. inline static const std::string kOAuth2DefaultScope = "catalog"; + /// \brief Default OAuth2 token endpoint path (relative to catalog URI). + inline static constexpr std::string_view kOAuth2DefaultTokenPath = "v1/oauth/tokens"; /// \brief Property key for SigV4 region. inline static const std::string kSigV4Region = "rest.auth.sigv4.region"; diff --git a/src/iceberg/catalog/rest/auth/auth_session.cc b/src/iceberg/catalog/rest/auth/auth_session.cc index 00ed946a9..2cfeb2c52 100644 --- a/src/iceberg/catalog/rest/auth/auth_session.cc +++ b/src/iceberg/catalog/rest/auth/auth_session.cc @@ -21,6 +21,8 @@ #include +#include "iceberg/catalog/rest/auth/oauth2_util.h" + namespace iceberg::rest::auth { namespace { @@ -49,4 +51,17 @@ std::shared_ptr AuthSession::MakeDefault( return std::make_shared(std::move(headers)); } +std::shared_ptr AuthSession::MakeOAuth2( + const OAuthTokenResponse& initial_token, const std::string& /*token_endpoint*/, + const std::string& /*client_id*/, const std::string& /*client_secret*/, + const std::string& /*scope*/, HttpClient& /*client*/) { + // TODO(lishuxu): Replace with OAuth2AuthSession that: + // - Stores access_token, refresh_token, expires_in, and token_endpoint + // - Checks token expiration in Authenticate() and transparently refreshes + // using RefreshToken() (or re-fetches via client_credentials if no + // refresh_token is available) + // For now, fall back to a static session with the initial token. + return MakeDefault({{"Authorization", "Bearer " + initial_token.access_token}}); +} + } // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/auth/auth_session.h b/src/iceberg/catalog/rest/auth/auth_session.h index d81b3d939..26b93877b 100644 --- a/src/iceberg/catalog/rest/auth/auth_session.h +++ b/src/iceberg/catalog/rest/auth/auth_session.h @@ -24,6 +24,7 @@ #include #include "iceberg/catalog/rest/iceberg_rest_export.h" +#include "iceberg/catalog/rest/type_fwd.h" #include "iceberg/result.h" /// \file iceberg/catalog/rest/auth/auth_session.h @@ -70,6 +71,27 @@ class ICEBERG_REST_EXPORT AuthSession { /// \return A new session that adds the given headers to requests. static std::shared_ptr MakeDefault( std::unordered_map headers); + + /// \brief Create an OAuth2 session with automatic token refresh. + /// + /// This factory method creates a session that holds an access token and + /// optionally a refresh token. When Authenticate() is called and the token + /// is expired, it transparently refreshes the token before setting the + /// Authorization header. + /// + /// \param initial_token The initial token response from FetchToken(). + /// \param token_endpoint Full URL of the OAuth2 token endpoint for refresh. + /// \param client_id OAuth2 client ID for refresh requests. + /// \param client_secret OAuth2 client secret for re-fetch if refresh fails. + /// \param scope OAuth2 scope for refresh requests. + /// \param client HTTP client for making refresh requests. + /// \return A new session that manages token lifecycle automatically. + static std::shared_ptr MakeOAuth2(const OAuthTokenResponse& initial_token, + const std::string& token_endpoint, + const std::string& client_id, + const std::string& client_secret, + const std::string& scope, + HttpClient& client); }; } // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/auth/oauth2_util.cc b/src/iceberg/catalog/rest/auth/oauth2_util.cc new file mode 100644 index 000000000..7254a6c84 --- /dev/null +++ b/src/iceberg/catalog/rest/auth/oauth2_util.cc @@ -0,0 +1,115 @@ +/* + * 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 "iceberg/catalog/rest/auth/oauth2_util.h" + +#include + +#include "iceberg/catalog/rest/auth/auth_session.h" +#include "iceberg/catalog/rest/error_handlers.h" +#include "iceberg/catalog/rest/http_client.h" +#include "iceberg/json_serde_internal.h" +#include "iceberg/util/json_util_internal.h" +#include "iceberg/util/macros.h" + +namespace iceberg::rest::auth { + +namespace { + +constexpr std::string_view kAccessToken = "access_token"; +constexpr std::string_view kTokenType = "token_type"; +constexpr std::string_view kExpiresIn = "expires_in"; +constexpr std::string_view kRefreshToken = "refresh_token"; +constexpr std::string_view kScope = "scope"; + +constexpr std::string_view kGrantType = "grant_type"; +constexpr std::string_view kClientCredentials = "client_credentials"; +constexpr std::string_view kClientId = "client_id"; +constexpr std::string_view kClientSecret = "client_secret"; + +} // namespace + +Status OAuthTokenResponse::Validate() const { + if (access_token.empty()) { + return ValidationFailed("OAuth2 token response missing required 'access_token'"); + } + if (token_type.empty()) { + return ValidationFailed("OAuth2 token response missing required 'token_type'"); + } + return {}; +} + +Result OAuthTokenResponseFromJsonString(const std::string& json_str) { + ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(json_str)); + + OAuthTokenResponse response; + ICEBERG_ASSIGN_OR_RAISE(response.access_token, + GetJsonValue(json, kAccessToken)); + ICEBERG_ASSIGN_OR_RAISE(response.token_type, + GetJsonValue(json, kTokenType)); + ICEBERG_ASSIGN_OR_RAISE(response.expires_in, + GetJsonValueOrDefault(json, kExpiresIn, 0)); + ICEBERG_ASSIGN_OR_RAISE(response.refresh_token, + GetJsonValueOrDefault(json, kRefreshToken)); + ICEBERG_ASSIGN_OR_RAISE(response.scope, + GetJsonValueOrDefault(json, kScope)); + ICEBERG_RETURN_UNEXPECTED(response.Validate()); + return response; +} + +Result FetchToken(HttpClient& client, + const std::string& token_endpoint, + const std::string& client_id, + const std::string& client_secret, + const std::string& scope, AuthSession& session) { + std::unordered_map form_data = { + {std::string(kGrantType), std::string(kClientCredentials)}, + {std::string(kClientId), client_id}, + {std::string(kClientSecret), client_secret}, + }; + if (!scope.empty()) { + form_data[std::string(kScope)] = scope; + } + + ICEBERG_ASSIGN_OR_RAISE(auto response, + client.PostForm(token_endpoint, form_data, /*headers=*/{}, + *DefaultErrorHandler::Instance(), session)); + + return OAuthTokenResponseFromJsonString(response.body()); +} + +Result RefreshToken(HttpClient& client, + const std::string& token_endpoint, + const std::string& client_id, + const std::string& refresh_token, + const std::string& scope, AuthSession& session) { + // TODO(lishuxu): Implement refresh_token grant type. + return NotImplemented("RefreshToken is not yet implemented"); +} + +Result ExchangeToken(HttpClient& client, + const std::string& token_endpoint, + const std::string& subject_token, + const std::string& subject_token_type, + const std::string& scope, AuthSession& session) { + // TODO(lishuxu): Implement token exchange (RFC 8693). + return NotImplemented("ExchangeToken is not yet implemented"); +} + +} // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/auth/oauth2_util.h b/src/iceberg/catalog/rest/auth/oauth2_util.h new file mode 100644 index 000000000..0d5b3621d --- /dev/null +++ b/src/iceberg/catalog/rest/auth/oauth2_util.h @@ -0,0 +1,109 @@ +/* + * 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 +#include + +#include "iceberg/catalog/rest/iceberg_rest_export.h" +#include "iceberg/catalog/rest/type_fwd.h" +#include "iceberg/result.h" + +/// \file iceberg/catalog/rest/auth/oauth2_util.h +/// \brief OAuth2 token utilities for REST catalog authentication. + +namespace iceberg::rest::auth { + +/// \brief Response from an OAuth2 token endpoint. +struct ICEBERG_REST_EXPORT OAuthTokenResponse { + std::string access_token; // required + std::string token_type; // required, typically "bearer" + int64_t expires_in = 0; // optional, seconds until expiration + std::string refresh_token; // optional + std::string scope; // optional + + /// \brief Validates the token response. + Status Validate() const; + + bool operator==(const OAuthTokenResponse&) const = default; +}; + +/// \brief Parse an OAuthTokenResponse from a JSON string. +/// +/// \param json_str The JSON string to parse. +/// \return The parsed token response or an error. +ICEBERG_REST_EXPORT Result OAuthTokenResponseFromJsonString( + const std::string& json_str); + +/// \brief Fetch an OAuth2 token using the client_credentials grant type. +/// +/// Sends a POST request with form-encoded body to the token endpoint: +/// grant_type=client_credentials&client_id=...&client_secret=...&scope=... +/// +/// \param client HTTP client to use for the request. +/// \param token_endpoint Full URL of the OAuth2 token endpoint. +/// \param client_id OAuth2 client ID. +/// \param client_secret OAuth2 client secret. +/// \param scope OAuth2 scope to request. +/// \param session Auth session for the request (typically a no-op session). +/// \return The token response or an error. +ICEBERG_REST_EXPORT Result FetchToken( + HttpClient& client, const std::string& token_endpoint, const std::string& client_id, + const std::string& client_secret, const std::string& scope, AuthSession& session); + +/// \brief Refresh an expired access token using a refresh_token grant. +/// +/// Sends a POST request with form-encoded body to the token endpoint: +/// grant_type=refresh_token&refresh_token=...&client_id=...&scope=... +/// +/// \param client HTTP client to use for the request. +/// \param token_endpoint Full URL of the OAuth2 token endpoint. +/// \param client_id OAuth2 client ID (may be empty if not required by server). +/// \param refresh_token The refresh token from a previous token response. +/// \param scope OAuth2 scope to request. +/// \param session Auth session for the request. +/// \return A new token response with a fresh access_token, or an error. +ICEBERG_REST_EXPORT Result RefreshToken( + HttpClient& client, const std::string& token_endpoint, const std::string& client_id, + const std::string& refresh_token, const std::string& scope, AuthSession& session); + +/// \brief Exchange a token for a scoped token using RFC 8693 Token Exchange. +/// +/// Sends a POST request with form-encoded body to the token endpoint: +/// grant_type=urn:ietf:params:oauth:grant-type:token-exchange +/// &subject_token=...&subject_token_type=...&scope=... +/// +/// Used by TableSession and ContextualSession to obtain table/context-specific +/// tokens from a parent session's access token. +/// +/// \param client HTTP client to use for the request. +/// \param token_endpoint Full URL of the OAuth2 token endpoint. +/// \param subject_token The access token to exchange. +/// \param subject_token_type Token type URI (typically +/// "urn:ietf:params:oauth:token-type:access_token"). +/// \param scope OAuth2 scope to request for the exchanged token. +/// \param session Auth session for the request. +/// \return A new token response with a scoped access_token, or an error. +ICEBERG_REST_EXPORT Result ExchangeToken( + HttpClient& client, const std::string& token_endpoint, + const std::string& subject_token, const std::string& subject_token_type, + const std::string& scope, AuthSession& session); + +} // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/http_client.cc b/src/iceberg/catalog/rest/http_client.cc index b0824621b..2e383b0ae 100644 --- a/src/iceberg/catalog/rest/http_client.cc +++ b/src/iceberg/catalog/rest/http_client.cc @@ -75,7 +75,7 @@ Result BuildHeaders( auth::AuthSession& session) { std::unordered_map headers(default_headers); for (const auto& [key, val] : request_headers) { - headers.emplace(key, val); + headers.insert_or_assign(key, val); } ICEBERG_RETURN_UNEXPECTED(session.Authenticate(headers)); return cpr::Header(headers.begin(), headers.end()); diff --git a/src/iceberg/catalog/rest/meson.build b/src/iceberg/catalog/rest/meson.build index 3a333963a..f1d4c33f5 100644 --- a/src/iceberg/catalog/rest/meson.build +++ b/src/iceberg/catalog/rest/meson.build @@ -19,6 +19,7 @@ iceberg_rest_sources = files( 'auth/auth_manager.cc', 'auth/auth_managers.cc', 'auth/auth_session.cc', + 'auth/oauth2_util.cc', 'catalog_properties.cc', 'endpoint.cc', 'error_handlers.cc', @@ -82,6 +83,7 @@ install_headers( 'auth/auth_managers.h', 'auth/auth_properties.h', 'auth/auth_session.h', + 'auth/oauth2_util.h', ], subdir: 'iceberg/catalog/rest/auth', ) diff --git a/src/iceberg/catalog/rest/rest_catalog.cc b/src/iceberg/catalog/rest/rest_catalog.cc index 94c6b1e4e..b7430974f 100644 --- a/src/iceberg/catalog/rest/rest_catalog.cc +++ b/src/iceberg/catalog/rest/rest_catalog.cc @@ -71,8 +71,19 @@ Result FetchServerConfig(const ResourcePaths& paths, auth::AuthSession& session) { ICEBERG_ASSIGN_OR_RAISE(auto config_path, paths.Config()); HttpClient client(current_config.ExtractHeaders()); + + // Send the client's warehouse location to the service to keep in sync. + // This is needed for cases where the warehouse is configured client side, but may + // be used on the server side, like the Hive Metastore, where both client and service + // may have a warehouse location. + std::unordered_map params; + std::string warehouse = current_config.Get(RestCatalogProperties::kWarehouse); + if (!warehouse.empty()) { + params[RestCatalogProperties::kWarehouse.key()] = std::move(warehouse); + } + ICEBERG_ASSIGN_OR_RAISE(const auto response, - client.Get(config_path, /*params=*/{}, /*headers=*/{}, + client.Get(config_path, params, /*headers=*/{}, *DefaultErrorHandler::Instance(), session)); ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.body())); return CatalogConfigFromJson(json); diff --git a/src/iceberg/catalog/rest/type_fwd.h b/src/iceberg/catalog/rest/type_fwd.h index e72861053..adb44b9a1 100644 --- a/src/iceberg/catalog/rest/type_fwd.h +++ b/src/iceberg/catalog/rest/type_fwd.h @@ -40,5 +40,6 @@ namespace iceberg::rest::auth { class AuthManager; class AuthSession; +struct OAuthTokenResponse; } // namespace iceberg::rest::auth diff --git a/src/iceberg/test/auth_manager_test.cc b/src/iceberg/test/auth_manager_test.cc index 82db393d0..6aad1d650 100644 --- a/src/iceberg/test/auth_manager_test.cc +++ b/src/iceberg/test/auth_manager_test.cc @@ -28,6 +28,7 @@ #include "iceberg/catalog/rest/auth/auth_managers.h" #include "iceberg/catalog/rest/auth/auth_properties.h" #include "iceberg/catalog/rest/auth/auth_session.h" +#include "iceberg/catalog/rest/auth/oauth2_util.h" #include "iceberg/catalog/rest/http_client.h" #include "iceberg/test/matchers.h" @@ -195,4 +196,109 @@ TEST_F(AuthManagerTest, RegisterCustomAuthManager) { EXPECT_EQ(headers["X-Custom-Auth"], "custom-value"); } +// Verifies OAuth2 with static token +TEST_F(AuthManagerTest, OAuth2StaticToken) { + std::unordered_map properties = { + {AuthProperties::kAuthType, "oauth2"}, + {AuthProperties::kOAuth2Token, "my-static-token"}, + }; + + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto session_result = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(session_result, IsOk()); + + std::unordered_map headers; + EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); + EXPECT_EQ(headers["Authorization"], "Bearer my-static-token"); +} + +// Verifies OAuth2 type is inferred from token property +TEST_F(AuthManagerTest, OAuth2InferredFromToken) { + std::unordered_map properties = { + {AuthProperties::kOAuth2Token, "inferred-token"}, + }; + + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto session_result = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(session_result, IsOk()); + + std::unordered_map headers; + EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); + EXPECT_EQ(headers["Authorization"], "Bearer inferred-token"); +} + +// Verifies OAuth2 returns unauthenticated session when neither token nor credential is +// provided +TEST_F(AuthManagerTest, OAuth2MissingCredentials) { + std::unordered_map properties = { + {AuthProperties::kAuthType, "oauth2"}, + }; + + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto session_result = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(session_result, IsOk()); + + // Session should have no auth headers + std::unordered_map headers; + ASSERT_TRUE(session_result.value()->Authenticate(headers).has_value()); + EXPECT_EQ(headers.find("Authorization"), headers.end()); +} + +// Verifies OAuth2 fails with invalid credential format +TEST_F(AuthManagerTest, OAuth2InvalidCredentialFormat) { + std::unordered_map properties = { + {AuthProperties::kAuthType, "oauth2"}, + {AuthProperties::kOAuth2Credential, "no-colon-separator"}, + {"uri", "http://localhost:8181"}, + }; + + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto session_result = manager_result.value()->CatalogSession(client_, properties); + EXPECT_THAT(session_result, IsError(ErrorKind::kInvalidArgument)); + EXPECT_THAT(session_result, HasErrorMessage("client_id:client_secret")); +} + +// Verifies OAuthTokenResponse JSON parsing +TEST_F(AuthManagerTest, OAuthTokenResponseParsing) { + std::string json = R"({ + "access_token": "test-access-token", + "token_type": "bearer", + "expires_in": 3600, + "refresh_token": "test-refresh-token", + "scope": "catalog" + })"; + + auto result = OAuthTokenResponseFromJsonString(json); + ASSERT_THAT(result, IsOk()); + EXPECT_EQ(result->access_token, "test-access-token"); + EXPECT_EQ(result->token_type, "bearer"); + EXPECT_EQ(result->expires_in, 3600); + EXPECT_EQ(result->refresh_token, "test-refresh-token"); + EXPECT_EQ(result->scope, "catalog"); +} + +// Verifies OAuthTokenResponse parsing with minimal fields +TEST_F(AuthManagerTest, OAuthTokenResponseMinimal) { + std::string json = R"({ + "access_token": "token123", + "token_type": "Bearer" + })"; + + auto result = OAuthTokenResponseFromJsonString(json); + ASSERT_THAT(result, IsOk()); + EXPECT_EQ(result->access_token, "token123"); + EXPECT_EQ(result->token_type, "Bearer"); + EXPECT_EQ(result->expires_in, 0); + EXPECT_TRUE(result->refresh_token.empty()); + EXPECT_TRUE(result->scope.empty()); +} + } // namespace iceberg::rest::auth From 0e741b91c70e6abbc22af7c9753946445fdbdfee Mon Sep 17 00:00:00 2001 From: "shuxu.li" Date: Thu, 26 Feb 2026 22:38:15 +0800 Subject: [PATCH 2/4] feat: Implement OAuth2 authentication for REST catalog --- src/iceberg/catalog/rest/auth/auth_manager.cc | 4 +-- src/iceberg/catalog/rest/auth/auth_session.cc | 7 +---- src/iceberg/catalog/rest/auth/oauth2_util.cc | 4 +-- src/iceberg/catalog/rest/auth/oauth2_util.h | 27 ------------------- 4 files changed, 4 insertions(+), 38 deletions(-) diff --git a/src/iceberg/catalog/rest/auth/auth_manager.cc b/src/iceberg/catalog/rest/auth/auth_manager.cc index 8c9b25672..c1d62e166 100644 --- a/src/iceberg/catalog/rest/auth/auth_manager.cc +++ b/src/iceberg/catalog/rest/auth/auth_manager.cc @@ -26,7 +26,6 @@ #include "iceberg/catalog/rest/auth/auth_session.h" #include "iceberg/catalog/rest/auth/oauth2_util.h" #include "iceberg/catalog/rest/catalog_properties.h" -#include "iceberg/catalog/rest/http_client.h" #include "iceberg/util/macros.h" #include "iceberg/util/transform_util.h" @@ -127,7 +126,6 @@ class OAuth2AuthManager : public AuthManager { Result> CatalogSession( HttpClient& client, const std::unordered_map& properties) override { - // Reuse the token fetched during InitSession. if (init_token_response_.has_value()) { auto token_response = std::move(*init_token_response_); init_token_response_.reset(); @@ -136,7 +134,7 @@ class OAuth2AuthManager : public AuthManager { ctx.client_secret, ctx.scope, client); } - // If both token and credential are provided, prefer the token. + // If token is provided, use it directly. auto token_it = properties.find(AuthProperties::kOAuth2Token); if (token_it != properties.end() && !token_it->second.empty()) { return AuthSession::MakeDefault({{"Authorization", "Bearer " + token_it->second}}); diff --git a/src/iceberg/catalog/rest/auth/auth_session.cc b/src/iceberg/catalog/rest/auth/auth_session.cc index 2cfeb2c52..3c06c384f 100644 --- a/src/iceberg/catalog/rest/auth/auth_session.cc +++ b/src/iceberg/catalog/rest/auth/auth_session.cc @@ -55,12 +55,7 @@ std::shared_ptr AuthSession::MakeOAuth2( const OAuthTokenResponse& initial_token, const std::string& /*token_endpoint*/, const std::string& /*client_id*/, const std::string& /*client_secret*/, const std::string& /*scope*/, HttpClient& /*client*/) { - // TODO(lishuxu): Replace with OAuth2AuthSession that: - // - Stores access_token, refresh_token, expires_in, and token_endpoint - // - Checks token expiration in Authenticate() and transparently refreshes - // using RefreshToken() (or re-fetches via client_credentials if no - // refresh_token is available) - // For now, fall back to a static session with the initial token. + // TODO(lishuxu): Create OAuth2AuthSession with auto-refresh support. return MakeDefault({{"Authorization", "Bearer " + initial_token.access_token}}); } diff --git a/src/iceberg/catalog/rest/auth/oauth2_util.cc b/src/iceberg/catalog/rest/auth/oauth2_util.cc index 7254a6c84..8e632d531 100644 --- a/src/iceberg/catalog/rest/auth/oauth2_util.cc +++ b/src/iceberg/catalog/rest/auth/oauth2_util.cc @@ -78,13 +78,13 @@ Result FetchToken(HttpClient& client, const std::string& client_id, const std::string& client_secret, const std::string& scope, AuthSession& session) { - std::unordered_map form_data = { + std::unordered_map form_data{ {std::string(kGrantType), std::string(kClientCredentials)}, {std::string(kClientId), client_id}, {std::string(kClientSecret), client_secret}, }; if (!scope.empty()) { - form_data[std::string(kScope)] = scope; + form_data.emplace(std::string(kScope), scope); } ICEBERG_ASSIGN_OR_RAISE(auto response, diff --git a/src/iceberg/catalog/rest/auth/oauth2_util.h b/src/iceberg/catalog/rest/auth/oauth2_util.h index 0d5b3621d..ba5015c47 100644 --- a/src/iceberg/catalog/rest/auth/oauth2_util.h +++ b/src/iceberg/catalog/rest/auth/oauth2_util.h @@ -69,38 +69,11 @@ ICEBERG_REST_EXPORT Result FetchToken( const std::string& client_secret, const std::string& scope, AuthSession& session); /// \brief Refresh an expired access token using a refresh_token grant. -/// -/// Sends a POST request with form-encoded body to the token endpoint: -/// grant_type=refresh_token&refresh_token=...&client_id=...&scope=... -/// -/// \param client HTTP client to use for the request. -/// \param token_endpoint Full URL of the OAuth2 token endpoint. -/// \param client_id OAuth2 client ID (may be empty if not required by server). -/// \param refresh_token The refresh token from a previous token response. -/// \param scope OAuth2 scope to request. -/// \param session Auth session for the request. -/// \return A new token response with a fresh access_token, or an error. ICEBERG_REST_EXPORT Result RefreshToken( HttpClient& client, const std::string& token_endpoint, const std::string& client_id, const std::string& refresh_token, const std::string& scope, AuthSession& session); /// \brief Exchange a token for a scoped token using RFC 8693 Token Exchange. -/// -/// Sends a POST request with form-encoded body to the token endpoint: -/// grant_type=urn:ietf:params:oauth:grant-type:token-exchange -/// &subject_token=...&subject_token_type=...&scope=... -/// -/// Used by TableSession and ContextualSession to obtain table/context-specific -/// tokens from a parent session's access token. -/// -/// \param client HTTP client to use for the request. -/// \param token_endpoint Full URL of the OAuth2 token endpoint. -/// \param subject_token The access token to exchange. -/// \param subject_token_type Token type URI (typically -/// "urn:ietf:params:oauth:token-type:access_token"). -/// \param scope OAuth2 scope to request for the exchanged token. -/// \param session Auth session for the request. -/// \return A new token response with a scoped access_token, or an error. ICEBERG_REST_EXPORT Result ExchangeToken( HttpClient& client, const std::string& token_endpoint, const std::string& subject_token, const std::string& subject_token_type, From 8432389d4e7fb01169f245d69cdcf3158e242cba Mon Sep 17 00:00:00 2001 From: "shuxu.li" Date: Mon, 9 Mar 2026 15:31:07 +0800 Subject: [PATCH 3/4] feat: Implement OAuth2 authentication for REST catalog --- src/iceberg/catalog/rest/auth/auth_manager.cc | 9 +++++---- src/iceberg/catalog/rest/auth/oauth2_util.cc | 6 +++++- src/iceberg/test/auth_manager_test.cc | 13 +++++++++---- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/iceberg/catalog/rest/auth/auth_manager.cc b/src/iceberg/catalog/rest/auth/auth_manager.cc index c1d62e166..c1d227aaf 100644 --- a/src/iceberg/catalog/rest/auth/auth_manager.cc +++ b/src/iceberg/catalog/rest/auth/auth_manager.cc @@ -180,11 +180,12 @@ class OAuth2AuthManager : public AuthManager { const auto& credential = credential_it->second; auto colon_pos = credential.find(':'); if (colon_pos == std::string::npos) { - return InvalidArgument( - "Invalid OAuth2 credential format: expected 'client_id:client_secret'"); + // No colon: treat entire string as client_secret with empty client_id. + ctx.client_secret = credential; + } else { + ctx.client_id = credential.substr(0, colon_pos); + ctx.client_secret = credential.substr(colon_pos + 1); } - ctx.client_id = credential.substr(0, colon_pos); - ctx.client_secret = credential.substr(colon_pos + 1); auto uri_it = properties.find(AuthProperties::kOAuth2ServerUri); if (uri_it != properties.end() && !uri_it->second.empty()) { diff --git a/src/iceberg/catalog/rest/auth/oauth2_util.cc b/src/iceberg/catalog/rest/auth/oauth2_util.cc index 8e632d531..e79310099 100644 --- a/src/iceberg/catalog/rest/auth/oauth2_util.cc +++ b/src/iceberg/catalog/rest/auth/oauth2_util.cc @@ -63,6 +63,8 @@ Result OAuthTokenResponseFromJsonString(const std::string& j GetJsonValue(json, kAccessToken)); ICEBERG_ASSIGN_OR_RAISE(response.token_type, GetJsonValue(json, kTokenType)); + // TODO(lishuxu): When implementing auto-refresh, extract exp claim from JWT if + // expires_in is missing. ICEBERG_ASSIGN_OR_RAISE(response.expires_in, GetJsonValueOrDefault(json, kExpiresIn, 0)); ICEBERG_ASSIGN_OR_RAISE(response.refresh_token, @@ -80,9 +82,11 @@ Result FetchToken(HttpClient& client, const std::string& scope, AuthSession& session) { std::unordered_map form_data{ {std::string(kGrantType), std::string(kClientCredentials)}, - {std::string(kClientId), client_id}, {std::string(kClientSecret), client_secret}, }; + if (!client_id.empty()) { + form_data.emplace(std::string(kClientId), client_id); + } if (!scope.empty()) { form_data.emplace(std::string(kScope), scope); } diff --git a/src/iceberg/test/auth_manager_test.cc b/src/iceberg/test/auth_manager_test.cc index 6aad1d650..fa7458993 100644 --- a/src/iceberg/test/auth_manager_test.cc +++ b/src/iceberg/test/auth_manager_test.cc @@ -251,10 +251,12 @@ TEST_F(AuthManagerTest, OAuth2MissingCredentials) { } // Verifies OAuth2 fails with invalid credential format -TEST_F(AuthManagerTest, OAuth2InvalidCredentialFormat) { +// Verifies credential without colon is treated as client_secret only +TEST_F(AuthManagerTest, OAuth2CredentialWithoutColon) { std::unordered_map properties = { {AuthProperties::kAuthType, "oauth2"}, - {AuthProperties::kOAuth2Credential, "no-colon-separator"}, + {AuthProperties::kOAuth2Credential, "secret-only"}, + {AuthProperties::kOAuth2Token, "my-static-token"}, {"uri", "http://localhost:8181"}, }; @@ -262,8 +264,11 @@ TEST_F(AuthManagerTest, OAuth2InvalidCredentialFormat) { ASSERT_THAT(manager_result, IsOk()); auto session_result = manager_result.value()->CatalogSession(client_, properties); - EXPECT_THAT(session_result, IsError(ErrorKind::kInvalidArgument)); - EXPECT_THAT(session_result, HasErrorMessage("client_id:client_secret")); + ASSERT_THAT(session_result, IsOk()); + + std::unordered_map headers; + ASSERT_THAT(session_result.value()->Authenticate(headers), IsOk()); + EXPECT_EQ(headers["Authorization"], "Bearer my-static-token"); } // Verifies OAuthTokenResponse JSON parsing From bf0f26eb273da94994a2f78fb612929390ad42c4 Mon Sep 17 00:00:00 2001 From: Gang Wu Date: Thu, 19 Mar 2026 11:15:30 +0800 Subject: [PATCH 4/4] redesign api --- src/iceberg/catalog/rest/CMakeLists.txt | 1 + src/iceberg/catalog/rest/auth/auth_manager.cc | 124 +++++------------- .../catalog/rest/auth/auth_manager_internal.h | 2 +- .../catalog/rest/auth/auth_managers.cc | 6 +- .../catalog/rest/auth/auth_properties.cc | 83 ++++++++++++ .../catalog/rest/auth/auth_properties.h | 95 +++++++++----- src/iceberg/catalog/rest/auth/auth_session.cc | 3 +- src/iceberg/catalog/rest/auth/oauth2_util.cc | 88 ++++--------- src/iceberg/catalog/rest/auth/oauth2_util.h | 56 +++----- src/iceberg/catalog/rest/json_serde.cc | 45 +++++++ .../catalog/rest/json_serde_internal.h | 1 + src/iceberg/catalog/rest/meson.build | 1 + src/iceberg/catalog/rest/type_fwd.h | 4 +- src/iceberg/catalog/rest/types.cc | 19 +++ src/iceberg/catalog/rest/types.h | 17 +++ src/iceberg/test/auth_manager_test.cc | 74 +++++++++-- 16 files changed, 372 insertions(+), 247 deletions(-) create mode 100644 src/iceberg/catalog/rest/auth/auth_properties.cc diff --git a/src/iceberg/catalog/rest/CMakeLists.txt b/src/iceberg/catalog/rest/CMakeLists.txt index 2fe8d21bf..e91b12962 100644 --- a/src/iceberg/catalog/rest/CMakeLists.txt +++ b/src/iceberg/catalog/rest/CMakeLists.txt @@ -20,6 +20,7 @@ add_subdirectory(auth) set(ICEBERG_REST_SOURCES auth/auth_manager.cc auth/auth_managers.cc + auth/auth_properties.cc auth/auth_session.cc auth/oauth2_util.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 c1d227aaf..47370bd3b 100644 --- a/src/iceberg/catalog/rest/auth/auth_manager.cc +++ b/src/iceberg/catalog/rest/auth/auth_manager.cc @@ -25,7 +25,6 @@ #include "iceberg/catalog/rest/auth/auth_properties.h" #include "iceberg/catalog/rest/auth/auth_session.h" #include "iceberg/catalog/rest/auth/oauth2_util.h" -#include "iceberg/catalog/rest/catalog_properties.h" #include "iceberg/util/macros.h" #include "iceberg/util/transform_util.h" @@ -84,7 +83,8 @@ class BasicAuthManager : public AuthManager { "Missing required property '{}'", AuthProperties::kBasicPassword); std::string credential = username_it->second + ":" + password_it->second; return AuthSession::MakeDefault( - {{"Authorization", "Basic " + TransformUtil::Base64Encode(credential)}}); + {{std::string(kAuthorizationHeader), + "Basic " + TransformUtil::Base64Encode(credential)}}); } }; @@ -95,29 +95,25 @@ Result> MakeBasicAuthManager( } /// \brief OAuth2 authentication manager. -/// -/// Two-phase init: InitSession fetches and caches a token for the config request; -/// CatalogSession reuses the cached token and enables refresh. -class OAuth2AuthManager : public AuthManager { +class OAuth2Manager : public AuthManager { public: Result> InitSession( HttpClient& init_client, const std::unordered_map& properties) override { + ICEBERG_ASSIGN_OR_RAISE(auto config, AuthProperties::FromProperties(properties)); + // No token refresh during init (short-lived session). + config.Set(AuthProperties::kKeepRefreshed, false); + // Credential takes priority: fetch a fresh token for the config request. - auto credential_it = properties.find(AuthProperties::kOAuth2Credential); - if (credential_it != properties.end() && !credential_it->second.empty()) { - ICEBERG_ASSIGN_OR_RAISE(auto ctx, ParseOAuth2Context(properties)); - auto noop_session = AuthSession::MakeDefault({}); + if (!config.credential().empty()) { + auto init_session = AuthSession::MakeDefault(AuthHeaders(config.token())); ICEBERG_ASSIGN_OR_RAISE(init_token_response_, - FetchToken(init_client, ctx.token_endpoint, ctx.client_id, - ctx.client_secret, ctx.scope, *noop_session)); - return AuthSession::MakeDefault( - {{"Authorization", "Bearer " + init_token_response_->access_token}}); + FetchToken(init_client, *init_session, config)); + return AuthSession::MakeDefault(AuthHeaders(init_token_response_->access_token)); } - auto token_it = properties.find(AuthProperties::kOAuth2Token); - if (token_it != properties.end() && !token_it->second.empty()) { - return AuthSession::MakeDefault({{"Authorization", "Bearer " + token_it->second}}); + if (!config.token().empty()) { + return AuthSession::MakeDefault(AuthHeaders(config.token())); } return AuthSession::MakeDefault({}); @@ -126,104 +122,48 @@ class OAuth2AuthManager : public AuthManager { Result> CatalogSession( HttpClient& client, const std::unordered_map& properties) override { + ICEBERG_ASSIGN_OR_RAISE(auto config, AuthProperties::FromProperties(properties)); + + // Reuse token from init phase. if (init_token_response_.has_value()) { auto token_response = std::move(*init_token_response_); init_token_response_.reset(); - ICEBERG_ASSIGN_OR_RAISE(auto ctx, ParseOAuth2Context(properties)); - return AuthSession::MakeOAuth2(token_response, ctx.token_endpoint, ctx.client_id, - ctx.client_secret, ctx.scope, client); + return AuthSession::MakeOAuth2(token_response, config.oauth2_server_uri(), + config.client_id(), config.client_secret(), + config.scope(), client); } // If token is provided, use it directly. - auto token_it = properties.find(AuthProperties::kOAuth2Token); - if (token_it != properties.end() && !token_it->second.empty()) { - return AuthSession::MakeDefault({{"Authorization", "Bearer " + token_it->second}}); + if (!config.token().empty()) { + return AuthSession::MakeDefault(AuthHeaders(config.token())); } // Fetch a new token using client_credentials grant. - auto credential_it = properties.find(AuthProperties::kOAuth2Credential); - if (credential_it != properties.end() && !credential_it->second.empty()) { - ICEBERG_ASSIGN_OR_RAISE(auto ctx, ParseOAuth2Context(properties)); - auto noop_session = AuthSession::MakeDefault({}); + if (!config.credential().empty()) { + auto base_session = AuthSession::MakeDefault(AuthHeaders(config.token())); OAuthTokenResponse token_response; - ICEBERG_ASSIGN_OR_RAISE(token_response, - FetchToken(client, ctx.token_endpoint, ctx.client_id, - ctx.client_secret, ctx.scope, *noop_session)); - return AuthSession::MakeOAuth2(token_response, ctx.token_endpoint, ctx.client_id, - ctx.client_secret, ctx.scope, client); + ICEBERG_ASSIGN_OR_RAISE(token_response, FetchToken(client, *base_session, config)); + // TODO(lishuxu): should we directly pass config to the MakeOAuth2 call? + return AuthSession::MakeOAuth2(token_response, config.oauth2_server_uri(), + config.client_id(), config.client_secret(), + config.scope(), client); } return AuthSession::MakeDefault({}); } - // TODO(lishuxu): Override TableSession() to support token exchange (RFC 8693). - // TODO(lishuxu): Override ContextualSession() to support per-context token exchange. + // TODO(lishuxu): Override TableSession() for token exchange (RFC 8693). + // TODO(lishuxu): Override ContextualSession() for per-context exchange. private: - struct OAuth2Context { - std::string client_id; - std::string client_secret; - std::string token_endpoint; - std::string scope; - }; - - /// \brief Parse credential, token endpoint, and scope from properties. - static Result ParseOAuth2Context( - const std::unordered_map& properties) { - OAuth2Context ctx; - - auto credential_it = properties.find(AuthProperties::kOAuth2Credential); - if (credential_it == properties.end() || credential_it->second.empty()) { - return InvalidArgument("OAuth2 authentication requires '{}' property", - AuthProperties::kOAuth2Credential); - } - const auto& credential = credential_it->second; - auto colon_pos = credential.find(':'); - if (colon_pos == std::string::npos) { - // No colon: treat entire string as client_secret with empty client_id. - ctx.client_secret = credential; - } else { - ctx.client_id = credential.substr(0, colon_pos); - ctx.client_secret = credential.substr(colon_pos + 1); - } - - auto uri_it = properties.find(AuthProperties::kOAuth2ServerUri); - if (uri_it != properties.end() && !uri_it->second.empty()) { - ctx.token_endpoint = uri_it->second; - } else { - // {uri}/v1/oauth/tokens. - auto catalog_uri_it = properties.find(RestCatalogProperties::kUri.key()); - if (catalog_uri_it == properties.end() || catalog_uri_it->second.empty()) { - return InvalidArgument( - "OAuth2 authentication requires '{}' or '{}' property to determine " - "token endpoint", - AuthProperties::kOAuth2ServerUri, RestCatalogProperties::kUri.key()); - } - std::string_view base = catalog_uri_it->second; - while (!base.empty() && base.back() == '/') { - base.remove_suffix(1); - } - ctx.token_endpoint = - std::string(base) + "/" + std::string(AuthProperties::kOAuth2DefaultTokenPath); - } - - ctx.scope = AuthProperties::kOAuth2DefaultScope; - auto scope_it = properties.find(AuthProperties::kOAuth2Scope); - if (scope_it != properties.end() && !scope_it->second.empty()) { - ctx.scope = scope_it->second; - } - - return ctx; - } - /// Cached token from InitSession std::optional init_token_response_; }; -Result> MakeOAuth2AuthManager( +Result> MakeOAuth2Manager( [[maybe_unused]] std::string_view name, [[maybe_unused]] const std::unordered_map& properties) { - return std::make_unique(); + return std::make_unique(); } } // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/auth/auth_manager_internal.h b/src/iceberg/catalog/rest/auth/auth_manager_internal.h index e6f2b7bff..051d05505 100644 --- a/src/iceberg/catalog/rest/auth/auth_manager_internal.h +++ b/src/iceberg/catalog/rest/auth/auth_manager_internal.h @@ -43,7 +43,7 @@ Result> MakeBasicAuthManager( const std::unordered_map& properties); /// \brief Create an OAuth2 authentication manager. -Result> MakeOAuth2AuthManager( +Result> MakeOAuth2Manager( std::string_view name, const std::unordered_map& properties); diff --git a/src/iceberg/catalog/rest/auth/auth_managers.cc b/src/iceberg/catalog/rest/auth/auth_managers.cc index 1410e1f87..f55885d75 100644 --- a/src/iceberg/catalog/rest/auth/auth_managers.cc +++ b/src/iceberg/catalog/rest/auth/auth_managers.cc @@ -52,8 +52,8 @@ std::string InferAuthType( } // Infer from OAuth2 properties (credential or token) - bool has_credential = properties.contains(AuthProperties::kOAuth2Credential); - bool has_token = properties.contains(AuthProperties::kOAuth2Token); + bool has_credential = properties.contains(AuthProperties::kCredential.key()); + bool has_token = properties.contains(AuthProperties::kToken.key()); if (has_credential || has_token) { return AuthProperties::kAuthTypeOAuth2; } @@ -65,7 +65,7 @@ AuthManagerRegistry CreateDefaultRegistry() { return { {AuthProperties::kAuthTypeNone, MakeNoopAuthManager}, {AuthProperties::kAuthTypeBasic, MakeBasicAuthManager}, - {AuthProperties::kAuthTypeOAuth2, MakeOAuth2AuthManager}, + {AuthProperties::kAuthTypeOAuth2, MakeOAuth2Manager}, }; } diff --git a/src/iceberg/catalog/rest/auth/auth_properties.cc b/src/iceberg/catalog/rest/auth/auth_properties.cc new file mode 100644 index 000000000..dcf16782c --- /dev/null +++ b/src/iceberg/catalog/rest/auth/auth_properties.cc @@ -0,0 +1,83 @@ +/* + * 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 "iceberg/catalog/rest/auth/auth_properties.h" + +#include + +#include "iceberg/catalog/rest/catalog_properties.h" + +namespace iceberg::rest::auth { + +namespace { + +std::pair ParseCredential(const std::string& credential) { + auto colon_pos = credential.find(':'); + if (colon_pos == std::string::npos) { + return {"", credential}; + } + return {credential.substr(0, colon_pos), credential.substr(colon_pos + 1)}; +} + +} // namespace + +std::unordered_map AuthProperties::optional_oauth_params() + const { + std::unordered_map params; + if (auto audience = Get(kAudience); !audience.empty()) { + params.emplace(kAudience.key(), std::move(audience)); + } + if (auto resource = Get(kResource); !resource.empty()) { + params.emplace(kResource.key(), std::move(resource)); + } + return params; +} + +Result AuthProperties::FromProperties( + const std::unordered_map& properties) { + AuthProperties config; + config.configs_ = properties; + + // Parse client_id/client_secret from credential + if (auto cred = config.credential(); !cred.empty()) { + auto [id, secret] = ParseCredential(cred); + config.client_id_ = std::move(id); + config.client_secret_ = std::move(secret); + } + + // Resolve token endpoint: if not explicitly set, derive from catalog URI + if (properties.find(kOAuth2ServerUri.key()) == properties.end() || + properties.at(kOAuth2ServerUri.key()).empty()) { + auto uri_it = properties.find(RestCatalogProperties::kUri.key()); + if (uri_it != properties.end() && !uri_it->second.empty()) { + std::string_view base = uri_it->second; + while (!base.empty() && base.back() == '/') { + base.remove_suffix(1); + } + config.Set(kOAuth2ServerUri, + std::string(base) + "/" + std::string(kOAuth2ServerUri.value())); + } + } + + // TODO(lishuxu): Parse JWT exp claim from token to set expires_at_millis_. + + return config; +} + +} // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/auth/auth_properties.h b/src/iceberg/catalog/rest/auth/auth_properties.h index 55e081ae6..05a7ea2c6 100644 --- a/src/iceberg/catalog/rest/auth/auth_properties.h +++ b/src/iceberg/catalog/rest/auth/auth_properties.h @@ -19,57 +19,88 @@ #pragma once +#include +#include #include -#include +#include + +#include "iceberg/catalog/rest/iceberg_rest_export.h" +#include "iceberg/result.h" +#include "iceberg/util/config.h" /// \file iceberg/catalog/rest/auth/auth_properties.h -/// \brief Property keys and constants for REST catalog authentication. +/// \brief Property keys and configuration for REST catalog authentication. namespace iceberg::rest::auth { -/// \brief Property keys and constants for authentication configuration. -/// -/// This struct defines all the property keys used to configure authentication -/// for the REST catalog. It follows the same naming conventions as Java Iceberg. -struct AuthProperties { - /// \brief Property key for specifying the authentication type. +/// \brief Authentication properties +class ICEBERG_REST_EXPORT AuthProperties : public ConfigBase { + public: + template + using Entry = const ConfigBase::Entry; + + // ---- Authentication type constants (not Entry-based) ---- + inline static const std::string kAuthType = "rest.auth.type"; - /// \brief Authentication type: no authentication. inline static const std::string kAuthTypeNone = "none"; - /// \brief Authentication type: HTTP Basic authentication. inline static const std::string kAuthTypeBasic = "basic"; - /// \brief Authentication type: OAuth2 authentication. inline static const std::string kAuthTypeOAuth2 = "oauth2"; - /// \brief Authentication type: AWS SigV4 authentication. inline static const std::string kAuthTypeSigV4 = "sigv4"; - /// \brief Property key for Basic auth username. + // ---- Basic auth entries ---- + inline static const std::string kBasicUsername = "rest.auth.basic.username"; - /// \brief Property key for Basic auth password. inline static const std::string kBasicPassword = "rest.auth.basic.password"; - /// \brief Property key for OAuth2 token (bearer token). - inline static const std::string kOAuth2Token = "token"; - /// \brief Property key for OAuth2 credential (client_id:client_secret). - inline static const std::string kOAuth2Credential = "credential"; - /// \brief Property key for OAuth2 scope. - inline static const std::string kOAuth2Scope = "scope"; - /// \brief Property key for OAuth2 server URI. - inline static const std::string kOAuth2ServerUri = "oauth2-server-uri"; - /// \brief Property key for enabling token refresh. - inline static const std::string kOAuth2TokenRefreshEnabled = "token-refresh-enabled"; - /// \brief Default OAuth2 scope for catalog operations. - inline static const std::string kOAuth2DefaultScope = "catalog"; - /// \brief Default OAuth2 token endpoint path (relative to catalog URI). - inline static constexpr std::string_view kOAuth2DefaultTokenPath = "v1/oauth/tokens"; - - /// \brief Property key for SigV4 region. + // ---- SigV4 entries ---- + inline static const std::string kSigV4Region = "rest.auth.sigv4.region"; - /// \brief Property key for SigV4 service name. inline static const std::string kSigV4Service = "rest.auth.sigv4.service"; - /// \brief Property key for SigV4 delegate auth type. inline static const std::string kSigV4DelegateAuthType = "rest.auth.sigv4.delegate-auth-type"; + + // ---- OAuth2 entries ---- + + inline static Entry kToken{"token", ""}; + inline static Entry kCredential{"credential", ""}; + inline static Entry kScope{"scope", "catalog"}; + inline static Entry kOAuth2ServerUri{"oauth2-server-uri", + "v1/oauth/tokens"}; + inline static Entry kKeepRefreshed{"token-refresh-enabled", true}; + inline static Entry kExchangeEnabled{"token-exchange-enabled", true}; + inline static Entry kAudience{"audience", ""}; + inline static Entry kResource{"resource", ""}; + + /// \brief Build an AuthProperties from a properties map. + static Result FromProperties( + const std::unordered_map& properties); + + /// \brief Get the bearer token. + std::string token() const { return Get(kToken); } + /// \brief Get the raw credential string. + std::string credential() const { return Get(kCredential); } + /// \brief Get the OAuth2 scope. + std::string scope() const { return Get(kScope); } + /// \brief Get the token endpoint URI. + std::string oauth2_server_uri() const { return Get(kOAuth2ServerUri); } + /// \brief Whether token refresh is enabled. + bool keep_refreshed() const { return Get(kKeepRefreshed); } + /// \brief Whether token exchange is enabled. + bool exchange_enabled() const { return Get(kExchangeEnabled); } + + /// \brief Parsed client_id from credential (empty if no colon). + const std::string& client_id() const { return client_id_; } + /// \brief Parsed client_secret from credential. + const std::string& client_secret() const { return client_secret_; } + + /// \brief Build optional OAuth params (audience, resource) from config. + std::unordered_map optional_oauth_params() const; + + private: + std::string client_id_; + std::string client_secret_; + std::string token_type_; + std::optional expires_at_millis_; }; } // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/auth/auth_session.cc b/src/iceberg/catalog/rest/auth/auth_session.cc index 3c06c384f..7251dc4a9 100644 --- a/src/iceberg/catalog/rest/auth/auth_session.cc +++ b/src/iceberg/catalog/rest/auth/auth_session.cc @@ -56,7 +56,8 @@ std::shared_ptr AuthSession::MakeOAuth2( const std::string& /*client_id*/, const std::string& /*client_secret*/, const std::string& /*scope*/, HttpClient& /*client*/) { // TODO(lishuxu): Create OAuth2AuthSession with auto-refresh support. - return MakeDefault({{"Authorization", "Bearer " + initial_token.access_token}}); + return MakeDefault({{std::string(kAuthorizationHeader), + std::string(kBearerPrefix) + initial_token.access_token}}); } } // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/auth/oauth2_util.cc b/src/iceberg/catalog/rest/auth/oauth2_util.cc index e79310099..3d209d2bd 100644 --- a/src/iceberg/catalog/rest/auth/oauth2_util.cc +++ b/src/iceberg/catalog/rest/auth/oauth2_util.cc @@ -19,101 +19,59 @@ #include "iceberg/catalog/rest/auth/oauth2_util.h" +#include + #include #include "iceberg/catalog/rest/auth/auth_session.h" #include "iceberg/catalog/rest/error_handlers.h" #include "iceberg/catalog/rest/http_client.h" +#include "iceberg/catalog/rest/json_serde_internal.h" #include "iceberg/json_serde_internal.h" -#include "iceberg/util/json_util_internal.h" #include "iceberg/util/macros.h" namespace iceberg::rest::auth { namespace { -constexpr std::string_view kAccessToken = "access_token"; -constexpr std::string_view kTokenType = "token_type"; -constexpr std::string_view kExpiresIn = "expires_in"; -constexpr std::string_view kRefreshToken = "refresh_token"; -constexpr std::string_view kScope = "scope"; - constexpr std::string_view kGrantType = "grant_type"; constexpr std::string_view kClientCredentials = "client_credentials"; constexpr std::string_view kClientId = "client_id"; constexpr std::string_view kClientSecret = "client_secret"; +constexpr std::string_view kScope = "scope"; } // namespace -Status OAuthTokenResponse::Validate() const { - if (access_token.empty()) { - return ValidationFailed("OAuth2 token response missing required 'access_token'"); - } - if (token_type.empty()) { - return ValidationFailed("OAuth2 token response missing required 'token_type'"); +std::unordered_map AuthHeaders(const std::string& token) { + if (!token.empty()) { + return {{std::string(kAuthorizationHeader), std::string(kBearerPrefix) + token}}; } return {}; } -Result OAuthTokenResponseFromJsonString(const std::string& json_str) { - ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(json_str)); - - OAuthTokenResponse response; - ICEBERG_ASSIGN_OR_RAISE(response.access_token, - GetJsonValue(json, kAccessToken)); - ICEBERG_ASSIGN_OR_RAISE(response.token_type, - GetJsonValue(json, kTokenType)); - // TODO(lishuxu): When implementing auto-refresh, extract exp claim from JWT if - // expires_in is missing. - ICEBERG_ASSIGN_OR_RAISE(response.expires_in, - GetJsonValueOrDefault(json, kExpiresIn, 0)); - ICEBERG_ASSIGN_OR_RAISE(response.refresh_token, - GetJsonValueOrDefault(json, kRefreshToken)); - ICEBERG_ASSIGN_OR_RAISE(response.scope, - GetJsonValueOrDefault(json, kScope)); - ICEBERG_RETURN_UNEXPECTED(response.Validate()); - return response; -} - -Result FetchToken(HttpClient& client, - const std::string& token_endpoint, - const std::string& client_id, - const std::string& client_secret, - const std::string& scope, AuthSession& session) { +Result FetchToken(HttpClient& client, AuthSession& session, + const AuthProperties& properties) { std::unordered_map form_data{ {std::string(kGrantType), std::string(kClientCredentials)}, - {std::string(kClientSecret), client_secret}, + {std::string(kClientSecret), properties.client_secret()}, + {std::string(kScope), properties.scope()}, }; - if (!client_id.empty()) { - form_data.emplace(std::string(kClientId), client_id); + if (!properties.client_id().empty()) { + form_data.emplace(std::string(kClientId), properties.client_id()); } - if (!scope.empty()) { - form_data.emplace(std::string(kScope), scope); + for (const auto& [key, value] : properties.optional_oauth_params()) { + form_data.emplace(key, value); } - ICEBERG_ASSIGN_OR_RAISE(auto response, - client.PostForm(token_endpoint, form_data, /*headers=*/{}, - *DefaultErrorHandler::Instance(), session)); - - return OAuthTokenResponseFromJsonString(response.body()); -} - -Result RefreshToken(HttpClient& client, - const std::string& token_endpoint, - const std::string& client_id, - const std::string& refresh_token, - const std::string& scope, AuthSession& session) { - // TODO(lishuxu): Implement refresh_token grant type. - return NotImplemented("RefreshToken is not yet implemented"); -} + ICEBERG_ASSIGN_OR_RAISE( + auto response, + client.PostForm(properties.oauth2_server_uri(), form_data, + /*headers=*/{}, *DefaultErrorHandler::Instance(), session)); -Result ExchangeToken(HttpClient& client, - const std::string& token_endpoint, - const std::string& subject_token, - const std::string& subject_token_type, - const std::string& scope, AuthSession& session) { - // TODO(lishuxu): Implement token exchange (RFC 8693). - return NotImplemented("ExchangeToken is not yet implemented"); + ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.body())); + ICEBERG_ASSIGN_OR_RAISE(auto token_response, FromJson(json)); + ICEBERG_RETURN_UNEXPECTED(token_response.Validate()); + return token_response; } } // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/auth/oauth2_util.h b/src/iceberg/catalog/rest/auth/oauth2_util.h index ba5015c47..39dd12964 100644 --- a/src/iceberg/catalog/rest/auth/oauth2_util.h +++ b/src/iceberg/catalog/rest/auth/oauth2_util.h @@ -19,11 +19,13 @@ #pragma once -#include #include +#include +#include #include "iceberg/catalog/rest/iceberg_rest_export.h" #include "iceberg/catalog/rest/type_fwd.h" +#include "iceberg/catalog/rest/types.h" #include "iceberg/result.h" /// \file iceberg/catalog/rest/auth/oauth2_util.h @@ -31,52 +33,24 @@ namespace iceberg::rest::auth { -/// \brief Response from an OAuth2 token endpoint. -struct ICEBERG_REST_EXPORT OAuthTokenResponse { - std::string access_token; // required - std::string token_type; // required, typically "bearer" - int64_t expires_in = 0; // optional, seconds until expiration - std::string refresh_token; // optional - std::string scope; // optional - - /// \brief Validates the token response. - Status Validate() const; - - bool operator==(const OAuthTokenResponse&) const = default; -}; - -/// \brief Parse an OAuthTokenResponse from a JSON string. -/// -/// \param json_str The JSON string to parse. -/// \return The parsed token response or an error. -ICEBERG_REST_EXPORT Result OAuthTokenResponseFromJsonString( - const std::string& json_str); +inline constexpr std::string_view kAuthorizationHeader = "Authorization"; +inline constexpr std::string_view kBearerPrefix = "Bearer "; /// \brief Fetch an OAuth2 token using the client_credentials grant type. /// -/// Sends a POST request with form-encoded body to the token endpoint: -/// grant_type=client_credentials&client_id=...&client_secret=...&scope=... -/// /// \param client HTTP client to use for the request. -/// \param token_endpoint Full URL of the OAuth2 token endpoint. -/// \param client_id OAuth2 client ID. -/// \param client_secret OAuth2 client secret. -/// \param scope OAuth2 scope to request. -/// \param session Auth session for the request (typically a no-op session). +/// \param session Auth session for the request headers. +/// \param properties Auth configuration containing credential, scope, +/// token endpoint, and optional OAuth params. /// \return The token response or an error. ICEBERG_REST_EXPORT Result FetchToken( - HttpClient& client, const std::string& token_endpoint, const std::string& client_id, - const std::string& client_secret, const std::string& scope, AuthSession& session); - -/// \brief Refresh an expired access token using a refresh_token grant. -ICEBERG_REST_EXPORT Result RefreshToken( - HttpClient& client, const std::string& token_endpoint, const std::string& client_id, - const std::string& refresh_token, const std::string& scope, AuthSession& session); + HttpClient& client, AuthSession& session, const AuthProperties& properties); -/// \brief Exchange a token for a scoped token using RFC 8693 Token Exchange. -ICEBERG_REST_EXPORT Result ExchangeToken( - HttpClient& client, const std::string& token_endpoint, - const std::string& subject_token, const std::string& subject_token_type, - const std::string& scope, AuthSession& session); +/// \brief Build auth headers from a token string. +/// +/// \param token Bearer token string (may be empty). +/// \return Headers map with Authorization header if token is non-empty. +ICEBERG_REST_EXPORT std::unordered_map AuthHeaders( + const std::string& token); } // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/json_serde.cc b/src/iceberg/catalog/rest/json_serde.cc index 727881f6e..eebdc1969 100644 --- a/src/iceberg/catalog/rest/json_serde.cc +++ b/src/iceberg/catalog/rest/json_serde.cc @@ -72,6 +72,12 @@ constexpr std::string_view kStack = "stack"; constexpr std::string_view kError = "error"; constexpr std::string_view kIdentifier = "identifier"; constexpr std::string_view kRequirements = "requirements"; +constexpr std::string_view kAccessToken = "access_token"; +constexpr std::string_view kTokenType = "token_type"; +constexpr std::string_view kExpiresIn = "expires_in"; +constexpr std::string_view kIssuedTokenType = "issued_token_type"; +constexpr std::string_view kRefreshToken = "refresh_token"; +constexpr std::string_view kOAuthScope = "scope"; } // namespace @@ -462,6 +468,44 @@ Result CommitTableResponseFromJson(const nlohmann::json& js return response; } +nlohmann::json ToJson(const OAuthTokenResponse& response) { + nlohmann::json json; + json[kAccessToken] = response.access_token; + json[kTokenType] = response.token_type; + if (response.expires_in_secs.has_value()) { + json[kExpiresIn] = response.expires_in_secs.value(); + } + if (!response.issued_token_type.empty()) { + json[kIssuedTokenType] = response.issued_token_type; + } + if (!response.scope.empty()) { + json[kOAuthScope] = response.scope; + } + return json; +} + +Result OAuthTokenResponseFromJson(const nlohmann::json& json) { + OAuthTokenResponse response; + ICEBERG_ASSIGN_OR_RAISE(response.access_token, + GetJsonValue(json, kAccessToken)); + ICEBERG_ASSIGN_OR_RAISE(response.token_type, + GetJsonValue(json, kTokenType)); + // TODO(lishuxu): When implementing auto-refresh, extract exp claim + // from JWT if expires_in is missing. + if (json.contains(std::string(kExpiresIn))) { + ICEBERG_ASSIGN_OR_RAISE(auto val, GetJsonValue(json, kExpiresIn)); + response.expires_in_secs = val; + } + ICEBERG_ASSIGN_OR_RAISE(response.issued_token_type, + GetJsonValueOrDefault(json, kIssuedTokenType)); + ICEBERG_ASSIGN_OR_RAISE(response.refresh_token, + GetJsonValueOrDefault(json, kRefreshToken)); + ICEBERG_ASSIGN_OR_RAISE(response.scope, + GetJsonValueOrDefault(json, kOAuthScope)); + ICEBERG_RETURN_UNEXPECTED(response.Validate()); + return response; +} + #define ICEBERG_DEFINE_FROM_JSON(Model) \ template <> \ Result FromJson(const nlohmann::json& json) { \ @@ -483,5 +527,6 @@ ICEBERG_DEFINE_FROM_JSON(RenameTableRequest) ICEBERG_DEFINE_FROM_JSON(CreateTableRequest) ICEBERG_DEFINE_FROM_JSON(CommitTableRequest) ICEBERG_DEFINE_FROM_JSON(CommitTableResponse) +ICEBERG_DEFINE_FROM_JSON(OAuthTokenResponse) } // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/json_serde_internal.h b/src/iceberg/catalog/rest/json_serde_internal.h index f8bdd78fb..820e077d7 100644 --- a/src/iceberg/catalog/rest/json_serde_internal.h +++ b/src/iceberg/catalog/rest/json_serde_internal.h @@ -58,6 +58,7 @@ ICEBERG_DECLARE_JSON_SERDE(RenameTableRequest) ICEBERG_DECLARE_JSON_SERDE(CreateTableRequest) ICEBERG_DECLARE_JSON_SERDE(CommitTableRequest) ICEBERG_DECLARE_JSON_SERDE(CommitTableResponse) +ICEBERG_DECLARE_JSON_SERDE(OAuthTokenResponse) #undef ICEBERG_DECLARE_JSON_SERDE diff --git a/src/iceberg/catalog/rest/meson.build b/src/iceberg/catalog/rest/meson.build index f1d4c33f5..ef2500456 100644 --- a/src/iceberg/catalog/rest/meson.build +++ b/src/iceberg/catalog/rest/meson.build @@ -18,6 +18,7 @@ iceberg_rest_sources = files( 'auth/auth_manager.cc', 'auth/auth_managers.cc', + 'auth/auth_properties.cc', 'auth/auth_session.cc', 'auth/oauth2_util.cc', 'catalog_properties.cc', diff --git a/src/iceberg/catalog/rest/type_fwd.h b/src/iceberg/catalog/rest/type_fwd.h index adb44b9a1..62bc14d12 100644 --- a/src/iceberg/catalog/rest/type_fwd.h +++ b/src/iceberg/catalog/rest/type_fwd.h @@ -22,10 +22,12 @@ /// \file iceberg/catalog/rest/type_fwd.h /// Forward declarations and enum definitions for Iceberg REST API types. +#include "iceberg/catalog/rest/auth/auth_properties.h" namespace iceberg::rest { struct ErrorResponse; struct LoadTableResult; +struct OAuthTokenResponse; class Endpoint; class ErrorHandler; @@ -39,7 +41,7 @@ class RestCatalogProperties; namespace iceberg::rest::auth { class AuthManager; +class AuthProperties; class AuthSession; -struct OAuthTokenResponse; } // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/types.cc b/src/iceberg/catalog/rest/types.cc index 3416bfe35..3abfb1406 100644 --- a/src/iceberg/catalog/rest/types.cc +++ b/src/iceberg/catalog/rest/types.cc @@ -19,6 +19,8 @@ #include "iceberg/catalog/rest/types.h" +#include + #include "iceberg/partition_spec.h" #include "iceberg/schema.h" #include "iceberg/sort_order.h" @@ -116,4 +118,21 @@ bool CommitTableResponse::operator==(const CommitTableResponse& other) const { return true; } +Status OAuthTokenResponse::Validate() const { + if (access_token.empty()) { + return ValidationFailed("OAuth2 token response missing required 'access_token'"); + } + if (token_type.empty()) { + return ValidationFailed("OAuth2 token response missing required 'token_type'"); + } + // token_type must be "bearer" or "N_A" (case-insensitive). + std::string lower_type = token_type; + std::ranges::transform(lower_type, lower_type.begin(), ::tolower); + if (lower_type != "bearer" && lower_type != "n_a") { + return ValidationFailed(R"(Unsupported token type: {} (must be "bearer" or "N_A"))", + token_type); + } + return {}; +} + } // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/types.h b/src/iceberg/catalog/rest/types.h index 93e7048a5..6495a6517 100644 --- a/src/iceberg/catalog/rest/types.h +++ b/src/iceberg/catalog/rest/types.h @@ -19,7 +19,9 @@ #pragma once +#include #include +#include #include #include #include @@ -278,4 +280,19 @@ struct ICEBERG_REST_EXPORT CommitTableResponse { bool operator==(const CommitTableResponse& other) const; }; +/// \brief Response from an OAuth2 token endpoint. +struct ICEBERG_REST_EXPORT OAuthTokenResponse { + std::string access_token; // required + std::string token_type; // required, "bearer" or "N_A" + std::optional expires_in_secs; // optional, seconds until expiration + std::string issued_token_type; // optional, for token exchange + std::string refresh_token; // optional + std::string scope; // optional + + /// \brief Validates the token response. + Status Validate() const; + + bool operator==(const OAuthTokenResponse&) const = default; +}; + } // namespace iceberg::rest diff --git a/src/iceberg/test/auth_manager_test.cc b/src/iceberg/test/auth_manager_test.cc index fa7458993..bd06fee3f 100644 --- a/src/iceberg/test/auth_manager_test.cc +++ b/src/iceberg/test/auth_manager_test.cc @@ -24,16 +24,29 @@ #include #include +#include #include "iceberg/catalog/rest/auth/auth_managers.h" #include "iceberg/catalog/rest/auth/auth_properties.h" #include "iceberg/catalog/rest/auth/auth_session.h" #include "iceberg/catalog/rest/auth/oauth2_util.h" #include "iceberg/catalog/rest/http_client.h" +#include "iceberg/catalog/rest/json_serde_internal.h" +#include "iceberg/json_serde_internal.h" #include "iceberg/test/matchers.h" namespace iceberg::rest::auth { +namespace { + +/// Helper to parse OAuthTokenResponse from a JSON string. +Result ParseTokenResponse(const std::string& str) { + ICEBERG_ASSIGN_OR_RAISE(auto json, iceberg::FromJsonString(str)); + return iceberg::rest::FromJson(json); +} + +} // namespace + class AuthManagerTest : public ::testing::Test { protected: HttpClient client_{{}}; @@ -200,7 +213,7 @@ TEST_F(AuthManagerTest, RegisterCustomAuthManager) { TEST_F(AuthManagerTest, OAuth2StaticToken) { std::unordered_map properties = { {AuthProperties::kAuthType, "oauth2"}, - {AuthProperties::kOAuth2Token, "my-static-token"}, + {AuthProperties::kToken.key(), "my-static-token"}, }; auto manager_result = AuthManagers::Load("test-catalog", properties); @@ -217,7 +230,7 @@ TEST_F(AuthManagerTest, OAuth2StaticToken) { // Verifies OAuth2 type is inferred from token property TEST_F(AuthManagerTest, OAuth2InferredFromToken) { std::unordered_map properties = { - {AuthProperties::kOAuth2Token, "inferred-token"}, + {AuthProperties::kToken.key(), "inferred-token"}, }; auto manager_result = AuthManagers::Load("test-catalog", properties); @@ -250,13 +263,13 @@ TEST_F(AuthManagerTest, OAuth2MissingCredentials) { EXPECT_EQ(headers.find("Authorization"), headers.end()); } -// Verifies OAuth2 fails with invalid credential format -// Verifies credential without colon is treated as client_secret only -TEST_F(AuthManagerTest, OAuth2CredentialWithoutColon) { +// Verifies that when both token and credential are provided, token takes priority +// in CatalogSession (without a prior InitSession call) +TEST_F(AuthManagerTest, OAuth2TokenTakesPriorityOverCredential) { std::unordered_map properties = { {AuthProperties::kAuthType, "oauth2"}, - {AuthProperties::kOAuth2Credential, "secret-only"}, - {AuthProperties::kOAuth2Token, "my-static-token"}, + {AuthProperties::kCredential.key(), "secret-only"}, + {AuthProperties::kToken.key(), "my-static-token"}, {"uri", "http://localhost:8181"}, }; @@ -277,15 +290,18 @@ TEST_F(AuthManagerTest, OAuthTokenResponseParsing) { "access_token": "test-access-token", "token_type": "bearer", "expires_in": 3600, + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", "refresh_token": "test-refresh-token", "scope": "catalog" })"; - auto result = OAuthTokenResponseFromJsonString(json); + auto result = ParseTokenResponse(json); ASSERT_THAT(result, IsOk()); EXPECT_EQ(result->access_token, "test-access-token"); EXPECT_EQ(result->token_type, "bearer"); - EXPECT_EQ(result->expires_in, 3600); + ASSERT_TRUE(result->expires_in_secs.has_value()); + EXPECT_EQ(result->expires_in_secs.value(), 3600); + EXPECT_EQ(result->issued_token_type, "urn:ietf:params:oauth:token-type:access_token"); EXPECT_EQ(result->refresh_token, "test-refresh-token"); EXPECT_EQ(result->scope, "catalog"); } @@ -297,13 +313,49 @@ TEST_F(AuthManagerTest, OAuthTokenResponseMinimal) { "token_type": "Bearer" })"; - auto result = OAuthTokenResponseFromJsonString(json); + auto result = ParseTokenResponse(json); ASSERT_THAT(result, IsOk()); EXPECT_EQ(result->access_token, "token123"); EXPECT_EQ(result->token_type, "Bearer"); - EXPECT_EQ(result->expires_in, 0); + EXPECT_FALSE(result->expires_in_secs.has_value()); + EXPECT_TRUE(result->issued_token_type.empty()); EXPECT_TRUE(result->refresh_token.empty()); EXPECT_TRUE(result->scope.empty()); } +// Verifies OAuthTokenResponse validation fails when access_token is missing +TEST_F(AuthManagerTest, OAuthTokenResponseMissingAccessToken) { + std::string json = R"({"token_type": "bearer"})"; + auto result = ParseTokenResponse(json); + EXPECT_THAT(result, ::testing::Not(IsOk())); +} + +// Verifies OAuthTokenResponse validation fails when token_type is missing +TEST_F(AuthManagerTest, OAuthTokenResponseMissingTokenType) { + std::string json = R"({"access_token": "token123"})"; + auto result = ParseTokenResponse(json); + EXPECT_THAT(result, ::testing::Not(IsOk())); +} + +// Verifies OAuthTokenResponse validation fails for unsupported token_type +TEST_F(AuthManagerTest, OAuthTokenResponseUnsupportedTokenType) { + std::string json = R"({ + "access_token": "token123", + "token_type": "mac" + })"; + auto result = ParseTokenResponse(json); + EXPECT_THAT(result, ::testing::Not(IsOk())); +} + +// Verifies OAuthTokenResponse accepts N_A token type +TEST_F(AuthManagerTest, OAuthTokenResponseNATokenType) { + std::string json = R"({ + "access_token": "token123", + "token_type": "N_A" + })"; + auto result = ParseTokenResponse(json); + ASSERT_THAT(result, IsOk()); + EXPECT_EQ(result->token_type, "N_A"); +} + } // namespace iceberg::rest::auth