From 6c7989892a0bf41fc60d9f0999cfe2ffccb36a3f Mon Sep 17 00:00:00 2001 From: "shuxu.li" Date: Sun, 2 Aug 2026 22:15:48 +0800 Subject: [PATCH] feat(rest): support OAuth token exchange sessions Add RFC 8693 token exchange support, including token type helpers, request form construction, OAuth endpoint normalization, and response handling. Preserve OAuth metadata in auth sessions and create contextual and table-scoped child sessions from direct tokens, credentials, or typed tokens. Disable child refresh until session lifecycle management is available. --- src/iceberg/catalog/rest/auth/auth_manager.cc | 112 ++++- .../catalog/rest/auth/auth_properties.cc | 40 +- .../catalog/rest/auth/auth_properties.h | 14 + src/iceberg/catalog/rest/auth/auth_session.cc | 55 ++- src/iceberg/catalog/rest/auth/auth_session.h | 14 + src/iceberg/catalog/rest/auth/oauth2_util.cc | 114 ++++- src/iceberg/catalog/rest/auth/oauth2_util.h | 48 ++ src/iceberg/catalog/rest/resource_paths.cc | 2 +- src/iceberg/catalog/rest/resource_paths.h | 2 +- src/iceberg/test/auth_manager_test.cc | 426 ++++++++++++++++++ .../test/rest_catalog_integration_test.cc | 103 +++++ src/iceberg/test/rest_util_test.cc | 8 + 12 files changed, 890 insertions(+), 48 deletions(-) diff --git a/src/iceberg/catalog/rest/auth/auth_manager.cc b/src/iceberg/catalog/rest/auth/auth_manager.cc index 10290489a..6b2a289ba 100644 --- a/src/iceberg/catalog/rest/auth/auth_manager.cc +++ b/src/iceberg/catalog/rest/auth/auth_manager.cc @@ -25,6 +25,7 @@ #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/session_context.h" #include "iceberg/util/base64.h" #include "iceberg/util/macros.h" @@ -121,6 +122,7 @@ class OAuth2Manager : public AuthManager { HttpClient& client, const std::unordered_map& properties) override { ICEBERG_ASSIGN_OR_RAISE(auto config, AuthProperties::FromProperties(properties)); + shared_client_ = &client; // Reuse token from init phase. if (init_token_response_.has_value()) { @@ -134,7 +136,15 @@ class OAuth2Manager : public AuthManager { // If token is provided, use it directly. if (!config.token().empty()) { - return AuthSession::MakeDefault(AuthHeaders(config.token())); + OAuthTokenResponse token_response{ + .access_token = config.token(), + .token_type = "bearer", + .issued_token_type = AuthProperties::kAccessTokenType, + }; + return AuthSession::MakeOAuth2(token_response, config.oauth2_server_uri(), + config.client_id(), config.client_secret(), + config.scope(), /*keep_refreshed=*/false, + config.optional_oauth_params(), client); } // Fetch a new token using client_credentials grant. @@ -148,15 +158,109 @@ class OAuth2Manager : public AuthManager { config.optional_oauth_params(), client); } - return AuthSession::MakeDefault({}); + return MakeSession(AccessTokenResponse(""), config, /*keep_refreshed=*/false); + } + + Result> ContextualSession( + const SessionContext& context, std::shared_ptr parent) override { + return MaybeCreateChildSession(context.credentials, /*allow_credential=*/true, + std::move(parent)); } - // TODO(lishuxu): Override TableSession() for token exchange (RFC 8693). - // TODO(lishuxu): Override ContextualSession() for per-context exchange. + Result> TableSession( + [[maybe_unused]] const TableIdentifier& table, + const std::unordered_map& properties, + std::shared_ptr parent) override { + return MaybeCreateChildSession(FilterTableSessionProperties(properties), + /*allow_credential=*/false, std::move(parent)); + } private: + static OAuthTokenResponse AccessTokenResponse(std::string token) { + return { + .access_token = std::move(token), + .token_type = "bearer", + .issued_token_type = AuthProperties::kAccessTokenType, + }; + } + + static Result ChildConfig(const OAuth2SessionInfo& parent_info, + const std::string& credential) { + auto properties = parent_info.optional_oauth_params; + properties[AuthProperties::kCredential.key()] = credential; + properties[AuthProperties::kScope.key()] = parent_info.scope; + properties[AuthProperties::kOAuth2ServerUri.key()] = parent_info.oauth2_server_uri; + return AuthProperties::FromProperties(properties); + } + + Result> MakeSession( + const OAuthTokenResponse& token_response, const AuthProperties& config, + bool keep_refreshed) const { + ICEBERG_PRECHECK(shared_client_ != nullptr, + "OAuth2 catalog session must be initialized before child sessions"); + return AuthSession::MakeOAuth2(token_response, config.oauth2_server_uri(), + config.client_id(), config.client_secret(), + config.scope(), keep_refreshed, + config.optional_oauth_params(), *shared_client_); + } + + Result> MaybeCreateChildSession( + const std::unordered_map& credentials, + bool allow_credential, std::shared_ptr parent) const { + auto token_it = credentials.find(AuthProperties::kToken.key()); + auto credential_it = credentials.find(AuthProperties::kCredential.key()); + auto typed_token = FindPreferredTypedToken(credentials); + if (token_it == credentials.end() && + (!allow_credential || credential_it == credentials.end()) && + !typed_token.has_value()) { + return parent; + } + + ICEBERG_PRECHECK(shared_client_ != nullptr, + "OAuth2 catalog session must be initialized before child sessions"); + auto parent_info = parent->OAuth2Info(); + ICEBERG_PRECHECK(parent_info.has_value(), + "OAuth2 child session requires OAuth2 parent metadata"); + + if (token_it != credentials.end()) { + ICEBERG_ASSIGN_OR_RAISE(auto config, + ChildConfig(*parent_info, parent_info->credential)); + return MakeSession(AccessTokenResponse(token_it->second), config, + /*keep_refreshed=*/false); + } + + if (allow_credential && credential_it != credentials.end()) { + ICEBERG_ASSIGN_OR_RAISE(auto config, + ChildConfig(*parent_info, credential_it->second)); + ICEBERG_ASSIGN_OR_RAISE(auto response, + FetchToken(*shared_client_, *parent, config)); + return MakeSession(response, config, /*keep_refreshed=*/false); + } + + std::optional actor; + if (!parent_info->token.empty()) { + actor = OAuth2Token{ + .token_type = parent_info->issued_token_type, + .token = parent_info->token, + }; + } + TokenExchangeRequest request{ + .oauth2_server_uri = parent_info->oauth2_server_uri, + .subject = std::move(*typed_token), + .actor = std::move(actor), + .scope = parent_info->scope, + .optional_oauth_params = parent_info->optional_oauth_params, + }; + ICEBERG_ASSIGN_OR_RAISE(auto response, + ExchangeToken(*shared_client_, *parent, {}, request)); + ICEBERG_ASSIGN_OR_RAISE(auto config, + ChildConfig(*parent_info, parent_info->credential)); + return MakeSession(response, config, /*keep_refreshed=*/false); + } + /// Cached token from InitSession std::optional init_token_response_; + HttpClient* shared_client_ = nullptr; }; Result> MakeOAuth2Manager( diff --git a/src/iceberg/catalog/rest/auth/auth_properties.cc b/src/iceberg/catalog/rest/auth/auth_properties.cc index dcf16782c..67d9319d8 100644 --- a/src/iceberg/catalog/rest/auth/auth_properties.cc +++ b/src/iceberg/catalog/rest/auth/auth_properties.cc @@ -22,6 +22,7 @@ #include #include "iceberg/catalog/rest/catalog_properties.h" +#include "iceberg/catalog/rest/rest_util.h" namespace iceberg::rest::auth { @@ -35,6 +36,30 @@ std::pair ParseCredential(const std::string& credentia return {credential.substr(0, colon_pos), credential.substr(colon_pos + 1)}; } +Result ResolveOAuth2ServerUri( + const std::unordered_map& properties) { + auto endpoint_it = properties.find(AuthProperties::kOAuth2ServerUri.key()); + std::string endpoint = endpoint_it == properties.end() || endpoint_it->second.empty() + ? AuthProperties::kOAuth2ServerUri.value() + : endpoint_it->second; + + if (endpoint.starts_with("http://") || endpoint.starts_with("https://")) { + return endpoint; + } + if (endpoint.starts_with('/')) { + return InvalidArgument("OAuth2 server URI path must not start with '/': {}", + endpoint); + } + + auto uri_it = properties.find(RestCatalogProperties::kUri.key()); + if (uri_it == properties.end() || uri_it->second.empty()) { + return endpoint; + } + + return std::string(TrimTrailingSlash(uri_it->second)) + "/" + + std::string(TrimTrailingSlash(endpoint)); +} + } // namespace std::unordered_map AuthProperties::optional_oauth_params() @@ -61,19 +86,8 @@ Result AuthProperties::FromProperties( 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())); - } - } + ICEBERG_ASSIGN_OR_RAISE(auto oauth2_server_uri, ResolveOAuth2ServerUri(properties)); + config.Set(kOAuth2ServerUri, std::move(oauth2_server_uri)); // TODO(lishuxu): Parse JWT exp claim from token to set expires_at_millis_. diff --git a/src/iceberg/catalog/rest/auth/auth_properties.h b/src/iceberg/catalog/rest/auth/auth_properties.h index a699569c1..8784194cc 100644 --- a/src/iceberg/catalog/rest/auth/auth_properties.h +++ b/src/iceberg/catalog/rest/auth/auth_properties.h @@ -82,6 +82,20 @@ class ICEBERG_REST_EXPORT AuthProperties : public ConfigBase { inline static Entry kAudience{"audience", ""}; inline static Entry kResource{"resource", ""}; + // ---- OAuth2 token type constants ---- + + inline static const std::string kAccessTokenType = + "urn:ietf:params:oauth:token-type:access_token"; + inline static const std::string kRefreshTokenType = + "urn:ietf:params:oauth:token-type:refresh_token"; + inline static const std::string kIdTokenType = + "urn:ietf:params:oauth:token-type:id_token"; + inline static const std::string kSaml1TokenType = + "urn:ietf:params:oauth:token-type:saml1"; + inline static const std::string kSaml2TokenType = + "urn:ietf:params:oauth:token-type:saml2"; + inline static const std::string kJwtTokenType = "urn:ietf:params:oauth:token-type:jwt"; + /// \brief Build an AuthProperties from a properties map. static Result FromProperties( const std::unordered_map& properties); diff --git a/src/iceberg/catalog/rest/auth/auth_session.cc b/src/iceberg/catalog/rest/auth/auth_session.cc index 545ee00b1..fbb1899d2 100644 --- a/src/iceberg/catalog/rest/auth/auth_session.cc +++ b/src/iceberg/catalog/rest/auth/auth_session.cc @@ -85,6 +85,18 @@ class OAuth2AuthSession : public AuthSession, return request; } + std::optional OAuth2Info() const override { + std::shared_lock lock(mutex_); + return OAuth2SessionInfo{ + .token = token_, + .issued_token_type = issued_token_type_, + .credential = Credential(config_), + .scope = config_.scope, + .oauth2_server_uri = config_.token_endpoint, + .optional_oauth_params = config_.optional_oauth_params, + }; + } + Status Close() override { return CloseImpl(); } ~OAuth2AuthSession() override { std::ignore = CloseImpl(); } @@ -107,12 +119,15 @@ class OAuth2AuthSession : public AuthSession, return {}; } + static std::string Credential(const Config& config) { + return config.client_id.empty() ? config.client_secret + : config.client_id + ":" + config.client_secret; + } + static Result MakeRefreshProperties(const Config& config) { std::unordered_map properties = config.optional_oauth_params; - properties[AuthProperties::kCredential.key()] = - config.client_id.empty() ? config.client_secret - : config.client_id + ":" + config.client_secret; + properties[AuthProperties::kCredential.key()] = Credential(config); properties[AuthProperties::kScope.key()] = config.scope; properties[AuthProperties::kOAuth2ServerUri.key()] = config.token_endpoint; @@ -141,11 +156,14 @@ class OAuth2AuthSession : public AuthSession, OAuth2AuthSession& session_; }; - void SetInitialToken(const OAuthTokenResponse& token_response) { + void UpdateTokenState(const OAuthTokenResponse& token_response) { token_ = token_response.access_token; - headers_ = {{std::string(kAuthorizationHeader), std::string(kBearerPrefix) + token_}}; + issued_token_type_ = token_response.issued_token_type.empty() + ? AuthProperties::kAccessTokenType + : token_response.issued_token_type; + headers_ = AuthHeaders(token_); - // Determine expiration time + expires_at_ = std::chrono::steady_clock::time_point{}; if (token_response.expires_in_secs.has_value()) { expires_at_ = std::chrono::steady_clock::now() + std::chrono::seconds(*token_response.expires_in_secs); @@ -157,6 +175,10 @@ class OAuth2AuthSession : public AuthSession, std::chrono::system_clock::time_point(std::chrono::milliseconds(*exp_ms)); expires_at_ = now_steady + (exp_sys - now_sys); } + } + + void SetInitialToken(const OAuthTokenResponse& token_response) { + UpdateTokenState(token_response); if (config_.keep_refreshed && expires_at_ != std::chrono::steady_clock::time_point{}) { @@ -184,23 +206,7 @@ class OAuth2AuthSession : public AuthSession, auto& response = result.value(); { std::unique_lock lock(mutex_); - token_ = response.access_token; - headers_ = { - {std::string(kAuthorizationHeader), std::string(kBearerPrefix) + token_}}; - - // Reset before deriving new expiry - expires_at_ = std::chrono::steady_clock::time_point{}; - - if (response.expires_in_secs.has_value()) { - expires_at_ = std::chrono::steady_clock::now() + - std::chrono::seconds(*response.expires_in_secs); - } else if (auto exp_ms = ExpiresAtMillis(token_); exp_ms.has_value()) { - auto now_sys = std::chrono::system_clock::now(); - auto now_steady = std::chrono::steady_clock::now(); - auto exp_sys = - std::chrono::system_clock::time_point(std::chrono::milliseconds(*exp_ms)); - expires_at_ = now_steady + (exp_sys - now_sys); - } + UpdateTokenState(response); } // Note: ScheduleRefresh must be called outside the lock. ScheduleRefresh(); @@ -262,8 +268,9 @@ class OAuth2AuthSession : public AuthSession, return std::max(wait_time, std::chrono::milliseconds(10)); } - mutable std::shared_mutex mutex_; // protects token_, headers_, expires_at_ + mutable std::shared_mutex mutex_; // protects token state, headers, and expiration std::string token_; + std::string issued_token_type_; std::unordered_map headers_; std::chrono::steady_clock::time_point expires_at_{}; diff --git a/src/iceberg/catalog/rest/auth/auth_session.h b/src/iceberg/catalog/rest/auth/auth_session.h index 3d0063a04..cfd32355a 100644 --- a/src/iceberg/catalog/rest/auth/auth_session.h +++ b/src/iceberg/catalog/rest/auth/auth_session.h @@ -20,6 +20,7 @@ #pragma once #include +#include #include #include @@ -33,6 +34,16 @@ namespace iceberg::rest::auth { +/// \brief OAuth2 metadata used to derive child authentication sessions. +struct ICEBERG_REST_EXPORT OAuth2SessionInfo { + std::string token; + std::string issued_token_type; + std::string credential; + std::string scope; + std::string oauth2_server_uri; + std::unordered_map optional_oauth_params; +}; + /// \brief An authentication session that can authenticate outgoing HTTP requests. class ICEBERG_REST_EXPORT AuthSession { public: @@ -54,6 +65,9 @@ class ICEBERG_REST_EXPORT AuthSession { /// - RestError: HTTP errors from authentication service virtual Result Authenticate(HttpRequest request) = 0; + /// \brief Return OAuth2 metadata when this is an OAuth2 session. + virtual std::optional OAuth2Info() const { return std::nullopt; } + /// \brief Close the session and release any resources. /// /// This method is called when the session is no longer needed. For stateful diff --git a/src/iceberg/catalog/rest/auth/oauth2_util.cc b/src/iceberg/catalog/rest/auth/oauth2_util.cc index d5e94821c..1f9da22b5 100644 --- a/src/iceberg/catalog/rest/auth/oauth2_util.cc +++ b/src/iceberg/catalog/rest/auth/oauth2_util.cc @@ -36,9 +36,22 @@ namespace { constexpr std::string_view kGrantType = "grant_type"; constexpr std::string_view kClientCredentials = "client_credentials"; +constexpr std::string_view kTokenExchange = + "urn:ietf:params:oauth:grant-type:token-exchange"; constexpr std::string_view kClientId = "client_id"; constexpr std::string_view kClientSecret = "client_secret"; constexpr std::string_view kScope = "scope"; +constexpr std::string_view kSubjectToken = "subject_token"; +constexpr std::string_view kSubjectTokenType = "subject_token_type"; +constexpr std::string_view kActorToken = "actor_token"; +constexpr std::string_view kActorTokenType = "actor_token_type"; + +Result ParseTokenResponse(const std::string& response_body) { + 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 @@ -49,6 +62,101 @@ std::unordered_map AuthHeaders(const std::string& toke return {}; } +bool IsValidTokenType(std::string_view token_type) { + return token_type == AuthProperties::kAccessTokenType || + token_type == AuthProperties::kRefreshTokenType || + token_type == AuthProperties::kIdTokenType || + token_type == AuthProperties::kSaml1TokenType || + token_type == AuthProperties::kSaml2TokenType || + token_type == AuthProperties::kJwtTokenType; +} + +std::array TokenPreferenceOrder() { + return { + std::string_view(AuthProperties::kIdTokenType), + std::string_view(AuthProperties::kAccessTokenType), + std::string_view(AuthProperties::kJwtTokenType), + std::string_view(AuthProperties::kSaml2TokenType), + std::string_view(AuthProperties::kSaml1TokenType), + }; +} + +std::optional FindPreferredTypedToken( + const std::unordered_map& credentials) { + for (std::string_view token_type : TokenPreferenceOrder()) { + auto token_it = credentials.find(std::string(token_type)); + if (token_it != credentials.end()) { + return OAuth2Token{ + .token_type = token_it->first, + .token = token_it->second, + }; + } + } + return std::nullopt; +} + +std::unordered_map FilterTableSessionProperties( + const std::unordered_map& properties) { + std::unordered_map filtered; + auto token_it = properties.find(AuthProperties::kToken.key()); + if (token_it != properties.end()) { + filtered.emplace(token_it->first, token_it->second); + } + for (std::string_view token_type : TokenPreferenceOrder()) { + auto token_it = properties.find(std::string(token_type)); + if (token_it != properties.end()) { + filtered.emplace(token_it->first, token_it->second); + } + } + return filtered; +} + +Result> BuildTokenExchangeForm( + const TokenExchangeRequest& request) { + if (request.subject.token.empty()) { + return InvalidArgument("OAuth2 subject token must not be empty"); + } + if (!IsValidTokenType(request.subject.token_type)) { + return InvalidArgument("Invalid OAuth2 subject token type: '{}'", + request.subject.token_type); + } + if (request.actor.has_value()) { + if (request.actor->token.empty()) { + return InvalidArgument("OAuth2 actor token must not be empty"); + } + if (!IsValidTokenType(request.actor->token_type)) { + return InvalidArgument("Invalid OAuth2 actor token type: '{}'", + request.actor->token_type); + } + } + + std::unordered_map form_data{ + {std::string(kGrantType), std::string(kTokenExchange)}, + {std::string(kScope), request.scope}, + {std::string(kSubjectToken), request.subject.token}, + {std::string(kSubjectTokenType), request.subject.token_type}, + }; + if (request.actor.has_value()) { + form_data.emplace(kActorToken, request.actor->token); + form_data.emplace(kActorTokenType, request.actor->token_type); + } + for (const auto& [key, value] : request.optional_oauth_params) { + form_data.insert_or_assign(key, value); + } + return form_data; +} + +Result ExchangeToken( + HttpClient& client, AuthSession& session, + const std::unordered_map& extra_headers, + const TokenExchangeRequest& request) { + ICEBERG_ASSIGN_OR_RAISE(auto form_data, BuildTokenExchangeForm(request)); + ICEBERG_ASSIGN_OR_RAISE( + auto response, client.PostForm(request.oauth2_server_uri, form_data, extra_headers, + *OAuthErrorHandler::Instance(), session)); + return ParseTokenResponse(response.body()); +} + Result FetchToken(HttpClient& client, AuthSession& session, const AuthProperties& properties) { std::unordered_map form_data{ @@ -67,11 +175,7 @@ Result FetchToken(HttpClient& client, AuthSession& session, auto response, client.PostForm(properties.oauth2_server_uri(), form_data, /*headers=*/{}, *OAuthErrorHandler::Instance(), session)); - - 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; + return ParseTokenResponse(response.body()); } std::optional ExpiresAtMillis(std::string_view token) { diff --git a/src/iceberg/catalog/rest/auth/oauth2_util.h b/src/iceberg/catalog/rest/auth/oauth2_util.h index 428ebc385..fe4f6723a 100644 --- a/src/iceberg/catalog/rest/auth/oauth2_util.h +++ b/src/iceberg/catalog/rest/auth/oauth2_util.h @@ -19,6 +19,7 @@ #pragma once +#include #include #include #include @@ -38,6 +39,19 @@ namespace iceberg::rest::auth { inline constexpr std::string_view kAuthorizationHeader = "Authorization"; inline constexpr std::string_view kBearerPrefix = "Bearer "; +struct ICEBERG_REST_EXPORT OAuth2Token { + std::string token_type; + std::string token; +}; + +struct ICEBERG_REST_EXPORT TokenExchangeRequest { + std::string oauth2_server_uri; + OAuth2Token subject; + std::optional actor; + std::string scope; + std::unordered_map optional_oauth_params; +}; + /// \brief Fetch an OAuth2 token using the client_credentials grant type. /// /// \param client HTTP client to use for the request. @@ -55,6 +69,40 @@ ICEBERG_REST_EXPORT Result FetchToken( ICEBERG_REST_EXPORT std::unordered_map AuthHeaders( const std::string& token); +/// \brief Return whether a token type is a supported RFC token type. +ICEBERG_REST_EXPORT bool IsValidTokenType(std::string_view token_type); + +/// \brief Return the preferred order for typed OAuth tokens. +ICEBERG_REST_EXPORT std::array TokenPreferenceOrder(); + +/// \brief Find the highest-preference typed OAuth token in credentials. +ICEBERG_REST_EXPORT std::optional FindPreferredTypedToken( + const std::unordered_map& credentials); + +/// \brief Filter table session properties to allowed OAuth credentials. +ICEBERG_REST_EXPORT std::unordered_map +FilterTableSessionProperties( + const std::unordered_map& properties); + +/// \brief Build RFC 8693 token exchange form data. +/// +/// \param request Token exchange request values. +/// \return Form data or an error if token values are invalid. +ICEBERG_REST_EXPORT Result> +BuildTokenExchangeForm(const TokenExchangeRequest& request); + +/// \brief Exchange an OAuth2 token using the RFC 8693 grant type. +/// +/// \param client HTTP client to use for the request. +/// \param session Auth session for the request headers. +/// \param extra_headers Request headers applied before session authentication. +/// \param request Token exchange endpoint and form values. +/// \return The token response or an error. +ICEBERG_REST_EXPORT Result ExchangeToken( + HttpClient& client, AuthSession& session, + const std::unordered_map& extra_headers, + const TokenExchangeRequest& request); + /// \brief Extract expiration time from a JWT token. /// /// Decodes the JWT payload (base64url) and reads the "exp" claim. diff --git a/src/iceberg/catalog/rest/resource_paths.cc b/src/iceberg/catalog/rest/resource_paths.cc index d18dd4636..3a70eb113 100644 --- a/src/iceberg/catalog/rest/resource_paths.cc +++ b/src/iceberg/catalog/rest/resource_paths.cc @@ -51,7 +51,7 @@ Result ResourcePaths::Config() const { } Result ResourcePaths::OAuth2Tokens() const { - return std::format("{}/v1/{}oauth/tokens", base_uri_, prefix_); + return std::format("{}/v1/oauth/tokens", base_uri_); } Result ResourcePaths::Namespaces() const { diff --git a/src/iceberg/catalog/rest/resource_paths.h b/src/iceberg/catalog/rest/resource_paths.h index 27135bb22..99e748231 100644 --- a/src/iceberg/catalog/rest/resource_paths.h +++ b/src/iceberg/catalog/rest/resource_paths.h @@ -49,7 +49,7 @@ class ICEBERG_REST_EXPORT ResourcePaths { /// \brief Get the /v1/config endpoint path. Result Config() const; - /// \brief Get the /v1/{prefix}/oauth/tokens endpoint path. + /// \brief Get the /v1/oauth/tokens endpoint path. Result OAuth2Tokens() const; /// \brief Get the /v1/{prefix}/namespaces endpoint path. diff --git a/src/iceberg/test/auth_manager_test.cc b/src/iceberg/test/auth_manager_test.cc index 19526b7e3..c9a03e91c 100644 --- a/src/iceberg/test/auth_manager_test.cc +++ b/src/iceberg/test/auth_manager_test.cc @@ -37,11 +37,13 @@ #include "iceberg/catalog/rest/auth/auth_session.h" #include "iceberg/catalog/rest/auth/oauth2_util.h" #include "iceberg/catalog/rest/auth/token_refresh_scheduler.h" +#include "iceberg/catalog/rest/catalog_properties.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/catalog/session_context.h" #include "iceberg/json_serde_internal.h" +#include "iceberg/table_identifier.h" #include "iceberg/test/matchers.h" #include "iceberg/util/base64.h" @@ -69,6 +71,258 @@ class AuthManagerTest : public ::testing::Test { HttpClient client_{{}}; }; +TEST(OAuth2UtilTest, TokenTypeConstantsUseRfcUrns) { + EXPECT_EQ(AuthProperties::kAccessTokenType, + "urn:ietf:params:oauth:token-type:access_token"); + EXPECT_EQ(AuthProperties::kRefreshTokenType, + "urn:ietf:params:oauth:token-type:refresh_token"); + EXPECT_EQ(AuthProperties::kIdTokenType, "urn:ietf:params:oauth:token-type:id_token"); + EXPECT_EQ(AuthProperties::kSaml1TokenType, "urn:ietf:params:oauth:token-type:saml1"); + EXPECT_EQ(AuthProperties::kSaml2TokenType, "urn:ietf:params:oauth:token-type:saml2"); + EXPECT_EQ(AuthProperties::kJwtTokenType, "urn:ietf:params:oauth:token-type:jwt"); +} + +TEST(OAuth2UtilTest, ValidTokenTypesIncludeRefreshToken) { + EXPECT_TRUE(IsValidTokenType(AuthProperties::kAccessTokenType)); + EXPECT_TRUE(IsValidTokenType(AuthProperties::kRefreshTokenType)); + EXPECT_TRUE(IsValidTokenType(AuthProperties::kIdTokenType)); + EXPECT_TRUE(IsValidTokenType(AuthProperties::kSaml1TokenType)); + EXPECT_TRUE(IsValidTokenType(AuthProperties::kSaml2TokenType)); + EXPECT_TRUE(IsValidTokenType(AuthProperties::kJwtTokenType)); + EXPECT_FALSE(IsValidTokenType("urn:ietf:params:oauth:token-type:unknown")); +} + +TEST(OAuth2UtilTest, TokenPreferenceOrder) { + auto order = TokenPreferenceOrder(); + ASSERT_EQ(order.size(), 5); + EXPECT_EQ(order[0], AuthProperties::kIdTokenType); + EXPECT_EQ(order[1], AuthProperties::kAccessTokenType); + EXPECT_EQ(order[2], AuthProperties::kJwtTokenType); + EXPECT_EQ(order[3], AuthProperties::kSaml2TokenType); + EXPECT_EQ(order[4], AuthProperties::kSaml1TokenType); +} + +TEST(OAuth2UtilTest, FindPreferredTypedTokenUsesPreferenceOrder) { + std::unordered_map credentials = { + {AuthProperties::kAccessTokenType, "access-token"}, + {AuthProperties::kJwtTokenType, "jwt-token"}, + {AuthProperties::kIdTokenType, "id-token"}, + {AuthProperties::kSaml2TokenType, "saml2-token"}, + {AuthProperties::kSaml1TokenType, "saml1-token"}, + }; + + auto token = FindPreferredTypedToken(credentials); + ASSERT_TRUE(token.has_value()); + EXPECT_EQ(token->token_type, AuthProperties::kIdTokenType); + EXPECT_EQ(token->token, "id-token"); + + credentials.erase(AuthProperties::kIdTokenType); + token = FindPreferredTypedToken(credentials); + ASSERT_TRUE(token.has_value()); + EXPECT_EQ(token->token_type, AuthProperties::kAccessTokenType); + EXPECT_EQ(token->token, "access-token"); + + credentials.clear(); + EXPECT_FALSE(FindPreferredTypedToken(credentials).has_value()); +} + +TEST(OAuth2UtilTest, FilterTableSessionPropertiesUsesAllowList) { + std::unordered_map properties = { + {AuthProperties::kToken.key(), "bearer-token"}, + {AuthProperties::kCredential.key(), "client:secret"}, + {AuthProperties::kScope.key(), "catalog"}, + {AuthProperties::kAccessTokenType, "access-token"}, + {AuthProperties::kRefreshTokenType, "refresh-token"}, + {AuthProperties::kIdTokenType, "id-token"}, + {AuthProperties::kJwtTokenType, "jwt-token"}, + {AuthProperties::kSaml2TokenType, "saml2-token"}, + {AuthProperties::kSaml1TokenType, "saml1-token"}, + {"unrelated", "value"}, + }; + + auto filtered = FilterTableSessionProperties(properties); + EXPECT_EQ(filtered.size(), 6); + EXPECT_EQ(filtered.at(AuthProperties::kToken.key()), "bearer-token"); + EXPECT_EQ(filtered.at(AuthProperties::kAccessTokenType), "access-token"); + EXPECT_EQ(filtered.at(AuthProperties::kIdTokenType), "id-token"); + EXPECT_EQ(filtered.at(AuthProperties::kJwtTokenType), "jwt-token"); + EXPECT_EQ(filtered.at(AuthProperties::kSaml2TokenType), "saml2-token"); + EXPECT_EQ(filtered.at(AuthProperties::kSaml1TokenType), "saml1-token"); + EXPECT_FALSE(filtered.contains(AuthProperties::kCredential.key())); + EXPECT_FALSE(filtered.contains(AuthProperties::kScope.key())); + EXPECT_FALSE(filtered.contains(AuthProperties::kRefreshTokenType)); + EXPECT_FALSE(filtered.contains("unrelated")); +} + +TEST(OAuth2UtilTest, BuildsTokenExchangeFormWithoutActor) { + TokenExchangeRequest request{ + .oauth2_server_uri = "https://auth.example.com/token", + .subject = + { + .token_type = AuthProperties::kIdTokenType, + .token = "subject-token", + }, + .scope = "catalog", + .optional_oauth_params = + { + {AuthProperties::kAudience.key(), "catalog-audience"}, + {AuthProperties::kResource.key(), "catalog-resource"}, + }, + }; + + ICEBERG_UNWRAP_OR_FAIL(auto form_data, BuildTokenExchangeForm(request)); + EXPECT_EQ(form_data.size(), 6); + EXPECT_EQ(form_data.at("grant_type"), + "urn:ietf:params:oauth:grant-type:token-exchange"); + EXPECT_EQ(form_data.at("scope"), "catalog"); + EXPECT_EQ(form_data.at("subject_token"), "subject-token"); + EXPECT_EQ(form_data.at("subject_token_type"), AuthProperties::kIdTokenType); + EXPECT_EQ(form_data.at("audience"), "catalog-audience"); + EXPECT_EQ(form_data.at("resource"), "catalog-resource"); + EXPECT_FALSE(form_data.contains("actor_token")); + EXPECT_FALSE(form_data.contains("actor_token_type")); +} + +TEST(OAuth2UtilTest, BuildsTokenExchangeFormWithActor) { + TokenExchangeRequest request{ + .subject = + { + .token_type = AuthProperties::kJwtTokenType, + .token = "subject-token", + }, + .actor = + OAuth2Token{ + .token_type = AuthProperties::kAccessTokenType, + .token = "actor-token", + }, + .scope = "catalog", + }; + + ICEBERG_UNWRAP_OR_FAIL(auto form_data, BuildTokenExchangeForm(request)); + EXPECT_EQ(form_data.size(), 6); + EXPECT_EQ(form_data.at("actor_token"), "actor-token"); + EXPECT_EQ(form_data.at("actor_token_type"), AuthProperties::kAccessTokenType); +} + +TEST(OAuth2UtilTest, TokenExchangeOptionalParamsUseLastValue) { + TokenExchangeRequest request{ + .subject = + { + .token_type = AuthProperties::kAccessTokenType, + .token = "subject-token", + }, + .scope = "catalog", + .optional_oauth_params = {{"scope", "custom-scope"}}, + }; + + ICEBERG_UNWRAP_OR_FAIL(auto form_data, BuildTokenExchangeForm(request)); + EXPECT_EQ(form_data.at("scope"), "custom-scope"); +} + +TEST(OAuth2UtilTest, RejectsInvalidTokenExchangeSubject) { + TokenExchangeRequest request{ + .subject = {.token_type = "invalid-token-type", .token = "subject-token"}, + }; + + auto invalid_type = BuildTokenExchangeForm(request); + EXPECT_THAT(invalid_type, IsError(ErrorKind::kInvalidArgument)); + EXPECT_THAT(invalid_type, HasErrorMessage("Invalid OAuth2 subject token type")); + + request.subject = { + .token_type = AuthProperties::kAccessTokenType, + .token = "", + }; + auto empty_token = BuildTokenExchangeForm(request); + EXPECT_THAT(empty_token, IsError(ErrorKind::kInvalidArgument)); + EXPECT_THAT(empty_token, HasErrorMessage("subject token must not be empty")); +} + +TEST(OAuth2UtilTest, RejectsInvalidTokenExchangeActor) { + TokenExchangeRequest request{ + .subject = + { + .token_type = AuthProperties::kAccessTokenType, + .token = "subject-token", + }, + .actor = OAuth2Token{.token_type = "invalid-token-type", .token = "actor-token"}, + }; + + auto invalid_type = BuildTokenExchangeForm(request); + EXPECT_THAT(invalid_type, IsError(ErrorKind::kInvalidArgument)); + EXPECT_THAT(invalid_type, HasErrorMessage("Invalid OAuth2 actor token type")); + + request.actor = OAuth2Token{ + .token_type = AuthProperties::kAccessTokenType, + .token = "", + }; + auto empty_token = BuildTokenExchangeForm(request); + EXPECT_THAT(empty_token, IsError(ErrorKind::kInvalidArgument)); + EXPECT_THAT(empty_token, HasErrorMessage("actor token must not be empty")); +} + +TEST(AuthPropertiesTest, ResolvesDefaultOAuth2ServerUri) { + ICEBERG_UNWRAP_OR_FAIL( + auto config, + AuthProperties::FromProperties({ + {RestCatalogProperties::kUri.key(), "https://catalog.example.com/api/"}, + {RestCatalogProperties::kPrefix.key(), "warehouse"}, + })); + + EXPECT_EQ(config.oauth2_server_uri(), + "https://catalog.example.com/api/v1/oauth/tokens"); +} + +TEST(AuthPropertiesTest, ResolvesEmptyOAuth2ServerUriToDefault) { + ICEBERG_UNWRAP_OR_FAIL( + auto config, AuthProperties::FromProperties({ + {RestCatalogProperties::kUri.key(), "https://catalog.example.com"}, + {AuthProperties::kOAuth2ServerUri.key(), ""}, + })); + + EXPECT_EQ(config.oauth2_server_uri(), "https://catalog.example.com/v1/oauth/tokens"); +} + +TEST(AuthPropertiesTest, ResolvesExplicitRelativeOAuth2ServerUri) { + ICEBERG_UNWRAP_OR_FAIL( + auto config, + AuthProperties::FromProperties({ + {RestCatalogProperties::kUri.key(), "https://catalog.example.com/api/"}, + {AuthProperties::kOAuth2ServerUri.key(), "oauth/token/"}, + })); + + EXPECT_EQ(config.oauth2_server_uri(), "https://catalog.example.com/api/oauth/token"); +} + +TEST(AuthPropertiesTest, PreservesExplicitAbsoluteOAuth2ServerUri) { + ICEBERG_UNWRAP_OR_FAIL( + auto config, + AuthProperties::FromProperties({ + {RestCatalogProperties::kUri.key(), "https://catalog.example.com"}, + {AuthProperties::kOAuth2ServerUri.key(), "https://auth.example.com/token/"}, + })); + + EXPECT_EQ(config.oauth2_server_uri(), "https://auth.example.com/token/"); +} + +TEST(AuthPropertiesTest, PreservesRelativeOAuth2ServerUriWithoutCatalogUri) { + ICEBERG_UNWRAP_OR_FAIL(auto config, + AuthProperties::FromProperties({ + {AuthProperties::kOAuth2ServerUri.key(), "oauth/token"}, + })); + + EXPECT_EQ(config.oauth2_server_uri(), "oauth/token"); +} + +TEST(AuthPropertiesTest, RejectsOAuth2ServerUriWithLeadingSlash) { + auto result = AuthProperties::FromProperties({ + {RestCatalogProperties::kUri.key(), "https://catalog.example.com"}, + {AuthProperties::kOAuth2ServerUri.key(), "/v1/oauth/tokens"}, + }); + + EXPECT_THAT(result, IsError(ErrorKind::kInvalidArgument)); + EXPECT_THAT(result, HasErrorMessage("must not start with '/'")); +} + // Verifies loading NoopAuthManager with explicit "none" auth type TEST_F(AuthManagerTest, LoadNoopAuthManagerExplicit) { std::unordered_map properties = { @@ -115,6 +369,61 @@ TEST_F(AuthManagerTest, HttpHeadersAreCaseInsensitiveSingleValueMap) { EXPECT_EQ(headers.at("AUTHORIZATION"), "Bearer first"); } +TEST_F(AuthManagerTest, DefaultSessionPreservesRequestAuthorizationHeader) { + auto session = AuthSession::MakeDefault(AuthHeaders("parent-token")); + + ICEBERG_UNWRAP_OR_FAIL( + auto authenticated, + session->Authenticate({.headers = {{"Authorization", "Basic credentials"}}})); + + EXPECT_EQ(authenticated.headers.at("Authorization"), "Basic credentials"); + EXPECT_FALSE(session->OAuth2Info().has_value()); +} + +TEST_F(AuthManagerTest, OAuth2SessionPreservesRequestAuthorizationHeader) { + OAuthTokenResponse token_response{ + .access_token = "parent-token", + .token_type = "bearer", + }; + ICEBERG_UNWRAP_OR_FAIL( + auto session, + AuthSession::MakeOAuth2(token_response, "https://auth.example.com/token", "", "", + "catalog", /*keep_refreshed=*/false, {}, client_)); + + ICEBERG_UNWRAP_OR_FAIL( + auto authenticated, + session->Authenticate({.headers = {{"Authorization", "Basic credentials"}}})); + + EXPECT_EQ(authenticated.headers.at("Authorization"), "Basic credentials"); + + ASSERT_TRUE(session->OAuth2Info().has_value()); + EXPECT_EQ(session->OAuth2Info()->issued_token_type, AuthProperties::kAccessTokenType); +} + +TEST_F(AuthManagerTest, OAuth2SessionExposesMetadata) { + OAuthTokenResponse token_response{ + .access_token = "parent-token", + .token_type = "bearer", + .issued_token_type = AuthProperties::kJwtTokenType, + }; + ICEBERG_UNWRAP_OR_FAIL( + auto session, + AuthSession::MakeOAuth2( + token_response, "https://auth.example.com/token", "client-id", "client-secret", + "catalog", /*keep_refreshed=*/false, + {{AuthProperties::kAudience.key(), "catalog-audience"}}, client_)); + + auto info = session->OAuth2Info(); + ASSERT_TRUE(info.has_value()); + EXPECT_EQ(info->token, "parent-token"); + EXPECT_EQ(info->issued_token_type, AuthProperties::kJwtTokenType); + EXPECT_EQ(info->credential, "client-id:client-secret"); + EXPECT_EQ(info->scope, "catalog"); + EXPECT_EQ(info->oauth2_server_uri, "https://auth.example.com/token"); + EXPECT_EQ(info->optional_oauth_params.at(AuthProperties::kAudience.key()), + "catalog-audience"); +} + TEST_F(AuthManagerTest, HttpClientRejectsParamsWhenUrlAlreadyHasQuery) { auto session = AuthSession::MakeDefault({}); auto result = @@ -266,6 +575,11 @@ TEST_F(AuthManagerTest, OAuth2StaticToken) { std::unordered_map properties = { {AuthProperties::kAuthType, "oauth2"}, {AuthProperties::kToken.key(), "my-static-token"}, + {AuthProperties::kCredential.key(), "client-id:client-secret"}, + {AuthProperties::kScope.key(), "catalog"}, + {AuthProperties::kOAuth2ServerUri.key(), "https://auth.example.com/token"}, + {AuthProperties::kAudience.key(), "catalog-audience"}, + {AuthProperties::kResource.key(), "catalog-resource"}, }; auto manager_result = AuthManagers::Load("test-catalog", properties); @@ -277,6 +591,18 @@ TEST_F(AuthManagerTest, OAuth2StaticToken) { auto auth_result = session_result.value()->Authenticate({}); ASSERT_THAT(auth_result, IsOk()); EXPECT_EQ(auth_result.value().headers["Authorization"], "Bearer my-static-token"); + + auto info = session_result.value()->OAuth2Info(); + ASSERT_TRUE(info.has_value()); + EXPECT_EQ(info->token, "my-static-token"); + EXPECT_EQ(info->issued_token_type, AuthProperties::kAccessTokenType); + EXPECT_EQ(info->credential, "client-id:client-secret"); + EXPECT_EQ(info->scope, "catalog"); + EXPECT_EQ(info->oauth2_server_uri, "https://auth.example.com/token"); + EXPECT_EQ(info->optional_oauth_params.at(AuthProperties::kAudience.key()), + "catalog-audience"); + EXPECT_EQ(info->optional_oauth_params.at(AuthProperties::kResource.key()), + "catalog-resource"); } // Verifies OAuth2 type is inferred from token property @@ -314,6 +640,106 @@ TEST_F(AuthManagerTest, OAuth2MissingCredentials) { ASSERT_TRUE(auth_result.has_value()); EXPECT_EQ(auth_result.value().headers.find("Authorization"), auth_result.value().headers.end()); + + auto info = session_result.value()->OAuth2Info(); + ASSERT_TRUE(info.has_value()); + EXPECT_TRUE(info->token.empty()); + EXPECT_EQ(info->issued_token_type, AuthProperties::kAccessTokenType); +} + +TEST_F(AuthManagerTest, OAuth2ContextTokenCreatesChildAndHasPriority) { + std::unordered_map properties = { + {AuthProperties::kAuthType, "oauth2"}, + {AuthProperties::kScope.key(), "catalog"}, + {AuthProperties::kOAuth2ServerUri.key(), "https://auth.example.com/token"}, + {AuthProperties::kAudience.key(), "catalog-audience"}, + }; + ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog", properties)); + ICEBERG_UNWRAP_OR_FAIL(auto parent, manager->CatalogSession(client_, properties)); + + SessionContext context{ + .session_id = "tenant-a", + .credentials = + { + {AuthProperties::kToken.key(), "context-token"}, + {AuthProperties::kCredential.key(), "unused-credential"}, + {AuthProperties::kIdTokenType, "unused-id-token"}, + }, + }; + ICEBERG_UNWRAP_OR_FAIL(auto child, manager->ContextualSession(context, parent)); + + EXPECT_NE(child, parent); + ICEBERG_UNWRAP_OR_FAIL(auto authenticated, child->Authenticate({})); + EXPECT_EQ(authenticated.headers.at("Authorization"), "Bearer context-token"); + + auto info = child->OAuth2Info(); + ASSERT_TRUE(info.has_value()); + EXPECT_EQ(info->token, "context-token"); + EXPECT_EQ(info->issued_token_type, AuthProperties::kAccessTokenType); + EXPECT_TRUE(info->credential.empty()); + EXPECT_EQ(info->scope, "catalog"); + EXPECT_EQ(info->oauth2_server_uri, "https://auth.example.com/token"); + EXPECT_EQ(info->optional_oauth_params.at(AuthProperties::kAudience.key()), + "catalog-audience"); +} + +TEST_F(AuthManagerTest, OAuth2ContextTypedTokenOnlyUsesCredentials) { + std::unordered_map properties = { + {AuthProperties::kAuthType, "oauth2"}, + }; + ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog", properties)); + ICEBERG_UNWRAP_OR_FAIL(auto parent, manager->CatalogSession(client_, properties)); + + SessionContext context{ + .session_id = "tenant-a", + .credentials = {{"unrelated", "value"}}, + .properties = {{AuthProperties::kIdTokenType, "property-id-token"}}, + }; + ICEBERG_UNWRAP_OR_FAIL(auto child, manager->ContextualSession(context, parent)); + + EXPECT_EQ(child, parent); +} + +TEST_F(AuthManagerTest, OAuth2TableTokenCreatesChild) { + std::unordered_map properties = { + {AuthProperties::kAuthType, "oauth2"}, + {AuthProperties::kScope.key(), "catalog"}, + {AuthProperties::kOAuth2ServerUri.key(), "https://auth.example.com/token"}, + }; + ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog", properties)); + ICEBERG_UNWRAP_OR_FAIL(auto parent, manager->CatalogSession(client_, properties)); + TableIdentifier table{.ns = Namespace{{"db"}}, .name = "table"}; + + ICEBERG_UNWRAP_OR_FAIL( + auto child, + manager->TableSession(table, + {{AuthProperties::kToken.key(), "table-token"}, + {AuthProperties::kCredential.key(), "ignored-credential"}}, + parent)); + + EXPECT_NE(child, parent); + ICEBERG_UNWRAP_OR_FAIL(auto authenticated, child->Authenticate({})); + EXPECT_EQ(authenticated.headers.at("Authorization"), "Bearer table-token"); + auto info = child->OAuth2Info(); + ASSERT_TRUE(info.has_value()); + EXPECT_EQ(info->issued_token_type, AuthProperties::kAccessTokenType); + EXPECT_TRUE(info->credential.empty()); +} + +TEST_F(AuthManagerTest, OAuth2TableIgnoresCredential) { + std::unordered_map properties = { + {AuthProperties::kAuthType, "oauth2"}, + }; + ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog", properties)); + ICEBERG_UNWRAP_OR_FAIL(auto parent, manager->CatalogSession(client_, properties)); + TableIdentifier table{.ns = Namespace{{"db"}}, .name = "table"}; + + ICEBERG_UNWRAP_OR_FAIL( + auto child, + manager->TableSession( + table, {{AuthProperties::kCredential.key(), "ignored-credential"}}, parent)); + + EXPECT_EQ(child, parent); } // Verifies that when both token and credential are provided, token takes priority diff --git a/src/iceberg/test/rest_catalog_integration_test.cc b/src/iceberg/test/rest_catalog_integration_test.cc index b449bc50f..9776d30d5 100644 --- a/src/iceberg/test/rest_catalog_integration_test.cc +++ b/src/iceberg/test/rest_catalog_integration_test.cc @@ -34,6 +34,8 @@ #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/catalog_properties.h" #include "iceberg/catalog/rest/error_handlers.h" @@ -101,6 +103,8 @@ bool CheckServiceReady(uint16_t port) { std::string CatalogUri() { return std::format("{}:{}", kLocalhostUri, kRestCatalogPort); } +std::string OAuthTokenUri() { return CatalogUri() + "/v1/oauth/tokens"; } + } // namespace /// \brief Integration test fixture for REST catalog with Docker Compose. @@ -211,6 +215,105 @@ TEST_F(RestCatalogIntegrationTest, MakeCatalogSuccess) { EXPECT_THAT(root->WithContext(SessionContext{}), IsError(ErrorKind::kInvalidArgument)); } +TEST_F(RestCatalogIntegrationTest, OAuthContextCredentialEndToEnd) { + HttpClient client; + std::unordered_map properties = { + {auth::AuthProperties::kAuthType, auth::AuthProperties::kAuthTypeOAuth2}, + {auth::AuthProperties::kToken.key(), "catalog-token"}, + {auth::AuthProperties::kOAuth2ServerUri.key(), OAuthTokenUri()}, + }; + ICEBERG_UNWRAP_OR_FAIL(auto manager, + auth::AuthManagers::Load("test-catalog", properties)); + ICEBERG_UNWRAP_OR_FAIL(auto parent, manager->CatalogSession(client, properties)); + SessionContext context{ + .session_id = "tenant-context-credential", + .credentials = {{auth::AuthProperties::kCredential.key(), "context-client:secret"}}, + }; + + ICEBERG_UNWRAP_OR_FAIL(auto child, manager->ContextualSession(context, parent)); + ICEBERG_UNWRAP_OR_FAIL(auto authenticated, child->Authenticate({})); + + EXPECT_EQ(authenticated.headers.at("Authorization"), + "Bearer client-credentials-token:sub=context-client"); + auto info = child->OAuth2Info(); + ASSERT_TRUE(info.has_value()); + EXPECT_EQ(info->issued_token_type, auth::AuthProperties::kAccessTokenType); +} + +TEST_F(RestCatalogIntegrationTest, OAuthContextTypedTokenEndToEnd) { + HttpClient client; + std::unordered_map properties = { + {auth::AuthProperties::kAuthType, auth::AuthProperties::kAuthTypeOAuth2}, + {auth::AuthProperties::kToken.key(), "catalog-token"}, + {auth::AuthProperties::kOAuth2ServerUri.key(), OAuthTokenUri()}, + }; + ICEBERG_UNWRAP_OR_FAIL(auto manager, + auth::AuthManagers::Load("test-catalog", properties)); + ICEBERG_UNWRAP_OR_FAIL(auto parent, manager->CatalogSession(client, properties)); + SessionContext context{ + .session_id = "tenant-context-token", + .credentials = {{auth::AuthProperties::kIdTokenType, "context-id-token"}}, + }; + + ICEBERG_UNWRAP_OR_FAIL(auto child, manager->ContextualSession(context, parent)); + ICEBERG_UNWRAP_OR_FAIL(auto authenticated, child->Authenticate({})); + + EXPECT_EQ(authenticated.headers.at("Authorization"), + "Bearer token-exchange-token:sub=context-id-token,act=catalog-token"); + auto info = child->OAuth2Info(); + ASSERT_TRUE(info.has_value()); + EXPECT_EQ(info->issued_token_type, auth::AuthProperties::kAccessTokenType); +} + +TEST_F(RestCatalogIntegrationTest, OAuthTableTypedTokenEndToEnd) { + HttpClient client; + std::unordered_map properties = { + {auth::AuthProperties::kAuthType, auth::AuthProperties::kAuthTypeOAuth2}, + {auth::AuthProperties::kToken.key(), "catalog-token"}, + {auth::AuthProperties::kOAuth2ServerUri.key(), OAuthTokenUri()}, + }; + ICEBERG_UNWRAP_OR_FAIL(auto manager, + auth::AuthManagers::Load("test-catalog", properties)); + ICEBERG_UNWRAP_OR_FAIL(auto parent, manager->CatalogSession(client, properties)); + TableIdentifier table{.ns = Namespace{{"db"}}, .name = "events"}; + + ICEBERG_UNWRAP_OR_FAIL( + auto child, + manager->TableSession( + table, {{auth::AuthProperties::kJwtTokenType, "table-jwt-token"}}, parent)); + ICEBERG_UNWRAP_OR_FAIL(auto authenticated, child->Authenticate({})); + + EXPECT_EQ(authenticated.headers.at("Authorization"), + "Bearer token-exchange-token:sub=table-jwt-token,act=catalog-token"); + auto info = child->OAuth2Info(); + ASSERT_TRUE(info.has_value()); + EXPECT_EQ(info->issued_token_type, auth::AuthProperties::kAccessTokenType); +} + +TEST_F(RestCatalogIntegrationTest, OAuthTokenExchangeWithoutActorEndToEnd) { + HttpClient client; + std::unordered_map properties = { + {auth::AuthProperties::kAuthType, auth::AuthProperties::kAuthTypeOAuth2}, + {auth::AuthProperties::kOAuth2ServerUri.key(), OAuthTokenUri()}, + }; + ICEBERG_UNWRAP_OR_FAIL(auto manager, + auth::AuthManagers::Load("test-catalog", properties)); + ICEBERG_UNWRAP_OR_FAIL(auto parent, manager->CatalogSession(client, properties)); + SessionContext context{ + .session_id = "tenant-no-actor", + .credentials = {{auth::AuthProperties::kIdTokenType, "context-id-token"}}, + }; + + ICEBERG_UNWRAP_OR_FAIL(auto child, manager->ContextualSession(context, parent)); + ICEBERG_UNWRAP_OR_FAIL(auto authenticated, child->Authenticate({})); + + EXPECT_EQ(authenticated.headers.at("Authorization"), + "Bearer token-exchange-token:sub=context-id-token"); + auto info = child->OAuth2Info(); + ASSERT_TRUE(info.has_value()); + EXPECT_EQ(info->issued_token_type, auth::AuthProperties::kAccessTokenType); +} + TEST_F(RestCatalogIntegrationTest, LoadsConfiguredMetricsReporter) { auto loaded = std::make_shared>(false); ASSERT_THAT(MetricsReporters::Register( diff --git a/src/iceberg/test/rest_util_test.cc b/src/iceberg/test/rest_util_test.cc index 0035afca0..6af3772d6 100644 --- a/src/iceberg/test/rest_util_test.cc +++ b/src/iceberg/test/rest_util_test.cc @@ -103,6 +103,14 @@ TEST(RestUtilTest, ResourcePathsRejectsEmptyNamespaceSeparator) { EXPECT_THAT(result, HasErrorMessage("REST namespace separator cannot be empty")); } +TEST(RestUtilTest, OAuth2TokensPathDoesNotUseCatalogPrefix) { + ICEBERG_UNWRAP_OR_FAIL( + auto paths, ResourcePaths::Make("https://catalog.example.com", "warehouse", "%1F")); + + EXPECT_THAT(paths->OAuth2Tokens(), + HasValue(::testing::Eq("https://catalog.example.com/v1/oauth/tokens"))); +} + TEST(RestUtilTest, EncodeString) { // RFC 3986 unreserved characters should not be encoded EXPECT_THAT(EncodeString("abc123XYZ"), HasValue(::testing::Eq("abc123XYZ")));