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")));