diff --git a/.github/workflows/cpp-linter.yml b/.github/workflows/cpp-linter.yml index 26324479c..1a257ee9d 100644 --- a/.github/workflows/cpp-linter.yml +++ b/.github/workflows/cpp-linter.yml @@ -37,14 +37,26 @@ jobs: uses: actions/checkout@v6 - name: Install dependencies shell: bash - run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev + run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev ninja-build + - name: Cache vcpkg packages + uses: actions/cache@v4 + id: vcpkg-cache + with: + path: /usr/local/share/vcpkg/installed + key: vcpkg-x64-linux-aws-sdk-cpp-core-${{ hashFiles('.github/workflows/cpp-linter.yml') }} + - name: Install AWS SDK via vcpkg + if: steps.vcpkg-cache.outputs.cache-hit != 'true' + shell: bash + run: vcpkg install aws-sdk-cpp[core]:x64-linux - name: Run build env: CC: gcc-14 CXX: g++-14 run: | mkdir build && cd build - cmake .. -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + cmake .. -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DICEBERG_BUILD_SIGV4=ON \ + -DCMAKE_TOOLCHAIN_FILE=/usr/local/share/vcpkg/scripts/buildsystems/vcpkg.cmake cmake --build . - uses: cpp-linter/cpp-linter-action@0f6d1b8d7e38b584cbee606eb23d850c217d54f8 id: linter diff --git a/CMakeLists.txt b/CMakeLists.txt index e7281fb11..0813463a5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,6 +45,7 @@ option(ICEBERG_BUILD_TESTS "Build tests" ON) option(ICEBERG_BUILD_BUNDLE "Build the battery included library" ON) option(ICEBERG_BUILD_REST "Build rest catalog client" ON) option(ICEBERG_BUILD_REST_INTEGRATION_TESTS "Build rest catalog integration tests" OFF) +option(ICEBERG_BUILD_SIGV4 "Build SigV4 authentication support (requires AWS SDK)" OFF) option(ICEBERG_ENABLE_ASAN "Enable Address Sanitizer" OFF) option(ICEBERG_ENABLE_UBSAN "Enable Undefined Behavior Sanitizer" OFF) diff --git a/cmake_modules/IcebergThirdpartyToolchain.cmake b/cmake_modules/IcebergThirdpartyToolchain.cmake index 8b32eb749..839be47e3 100644 --- a/cmake_modules/IcebergThirdpartyToolchain.cmake +++ b/cmake_modules/IcebergThirdpartyToolchain.cmake @@ -531,3 +531,21 @@ endif() if(ICEBERG_BUILD_REST) resolve_cpr_dependency() endif() + +# ---------------------------------------------------------------------- +# AWS SDK for C++ + +function(resolve_aws_sdk_dependency) + find_package(AWSSDK REQUIRED COMPONENTS core) + list(APPEND ICEBERG_SYSTEM_DEPENDENCIES AWSSDK) + set(ICEBERG_SYSTEM_DEPENDENCIES + ${ICEBERG_SYSTEM_DEPENDENCIES} + PARENT_SCOPE) +endfunction() + +if(ICEBERG_BUILD_SIGV4) + if(NOT ICEBERG_BUILD_REST) + message(FATAL_ERROR "ICEBERG_BUILD_SIGV4 requires ICEBERG_BUILD_REST to be ON") + endif() + resolve_aws_sdk_dependency() +endif() diff --git a/meson.options b/meson.options index 9152af34d..c53574889 100644 --- a/meson.options +++ b/meson.options @@ -44,4 +44,11 @@ option( value: 'disabled', ) +option( + 'sigv4', + type: 'feature', + description: 'Build AWS SigV4 authentication support for rest catalog', + value: 'disabled', +) + option('tests', type: 'feature', description: 'Build tests', value: 'enabled') diff --git a/src/iceberg/catalog/rest/CMakeLists.txt b/src/iceberg/catalog/rest/CMakeLists.txt index e91b12962..028dcc4f3 100644 --- a/src/iceberg/catalog/rest/CMakeLists.txt +++ b/src/iceberg/catalog/rest/CMakeLists.txt @@ -33,6 +33,10 @@ set(ICEBERG_REST_SOURCES rest_util.cc types.cc) +if(ICEBERG_BUILD_SIGV4) + list(APPEND ICEBERG_REST_SOURCES auth/sigv4_auth_manager.cc) +endif() + set(ICEBERG_REST_STATIC_BUILD_INTERFACE_LIBS) set(ICEBERG_REST_SHARED_BUILD_INTERFACE_LIBS) set(ICEBERG_REST_STATIC_INSTALL_INTERFACE_LIBS) @@ -51,6 +55,13 @@ list(APPEND "$,iceberg::iceberg_shared,iceberg::iceberg_static>" "$,iceberg::cpr,cpr::cpr>") +if(ICEBERG_BUILD_SIGV4) + list(APPEND ICEBERG_REST_STATIC_BUILD_INTERFACE_LIBS aws-cpp-sdk-core) + list(APPEND ICEBERG_REST_SHARED_BUILD_INTERFACE_LIBS aws-cpp-sdk-core) + list(APPEND ICEBERG_REST_STATIC_INSTALL_INTERFACE_LIBS aws-cpp-sdk-core) + list(APPEND ICEBERG_REST_SHARED_INSTALL_INTERFACE_LIBS aws-cpp-sdk-core) +endif() + add_iceberg_lib(iceberg_rest SOURCES ${ICEBERG_REST_SOURCES} @@ -63,4 +74,12 @@ add_iceberg_lib(iceberg_rest SHARED_INSTALL_INTERFACE_LIBS ${ICEBERG_REST_SHARED_INSTALL_INTERFACE_LIBS}) +if(ICEBERG_BUILD_SIGV4) + foreach(LIB iceberg_rest_static iceberg_rest_shared) + if(TARGET ${LIB}) + target_compile_definitions(${LIB} PUBLIC ICEBERG_BUILD_SIGV4) + endif() + endforeach() +endif() + iceberg_install_all_headers(iceberg/catalog/rest) diff --git a/src/iceberg/catalog/rest/auth/auth_manager_internal.h b/src/iceberg/catalog/rest/auth/auth_manager_internal.h index 051d05505..783fb2e70 100644 --- a/src/iceberg/catalog/rest/auth/auth_manager_internal.h +++ b/src/iceberg/catalog/rest/auth/auth_manager_internal.h @@ -47,4 +47,11 @@ Result> MakeOAuth2Manager( std::string_view name, const std::unordered_map& properties); +#ifdef ICEBERG_BUILD_SIGV4 +/// \brief Create a SigV4 authentication manager with a delegate. +Result> MakeSigV4AuthManager( + std::string_view name, + const std::unordered_map& properties); +#endif + } // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/auth/auth_managers.cc b/src/iceberg/catalog/rest/auth/auth_managers.cc index f55885d75..0a1d12788 100644 --- a/src/iceberg/catalog/rest/auth/auth_managers.cc +++ b/src/iceberg/catalog/rest/auth/auth_managers.cc @@ -62,11 +62,15 @@ std::string InferAuthType( } AuthManagerRegistry CreateDefaultRegistry() { - return { + AuthManagerRegistry registry = { {AuthProperties::kAuthTypeNone, MakeNoopAuthManager}, {AuthProperties::kAuthTypeBasic, MakeBasicAuthManager}, {AuthProperties::kAuthTypeOAuth2, MakeOAuth2Manager}, }; +#ifdef ICEBERG_BUILD_SIGV4 + registry[AuthProperties::kAuthTypeSigV4] = MakeSigV4AuthManager; +#endif + return registry; } // Get the global registry of auth manager factories. diff --git a/src/iceberg/catalog/rest/auth/auth_properties.h b/src/iceberg/catalog/rest/auth/auth_properties.h index 05a7ea2c6..f6dfc4ae8 100644 --- a/src/iceberg/catalog/rest/auth/auth_properties.h +++ b/src/iceberg/catalog/rest/auth/auth_properties.h @@ -54,10 +54,14 @@ class ICEBERG_REST_EXPORT AuthProperties : public ConfigBase { // ---- SigV4 entries ---- - inline static const std::string kSigV4Region = "rest.auth.sigv4.region"; - inline static const std::string kSigV4Service = "rest.auth.sigv4.service"; inline static const std::string kSigV4DelegateAuthType = "rest.auth.sigv4.delegate-auth-type"; + inline static const std::string kSigV4SigningRegion = "rest.signing-region"; + inline static const std::string kSigV4SigningName = "rest.signing-name"; + inline static const std::string kSigV4SigningNameDefault = "execute-api"; + inline static const std::string kSigV4AccessKeyId = "rest.access-key-id"; + inline static const std::string kSigV4SecretAccessKey = "rest.secret-access-key"; + inline static const std::string kSigV4SessionToken = "rest.session-token"; // ---- OAuth2 entries ---- diff --git a/src/iceberg/catalog/rest/auth/auth_session.cc b/src/iceberg/catalog/rest/auth/auth_session.cc index 7251dc4a9..3d6788479 100644 --- a/src/iceberg/catalog/rest/auth/auth_session.cc +++ b/src/iceberg/catalog/rest/auth/auth_session.cc @@ -33,11 +33,12 @@ class DefaultAuthSession : public AuthSession { explicit DefaultAuthSession(std::unordered_map headers) : headers_(std::move(headers)) {} - Status Authenticate(std::unordered_map& headers) override { + Result Authenticate(const HTTPRequest& request) override { + HTTPRequest authenticated = request; for (const auto& [key, value] : headers_) { - headers.try_emplace(key, value); + authenticated.headers.try_emplace(key, value); } - return {}; + return authenticated; } private: diff --git a/src/iceberg/catalog/rest/auth/auth_session.h b/src/iceberg/catalog/rest/auth/auth_session.h index 26b93877b..396d529e4 100644 --- a/src/iceberg/catalog/rest/auth/auth_session.h +++ b/src/iceberg/catalog/rest/auth/auth_session.h @@ -23,6 +23,7 @@ #include #include +#include "iceberg/catalog/rest/endpoint.h" #include "iceberg/catalog/rest/iceberg_rest_export.h" #include "iceberg/catalog/rest/type_fwd.h" #include "iceberg/result.h" @@ -32,25 +33,37 @@ namespace iceberg::rest::auth { +/// \brief An outgoing HTTP request passed through an AuthSession. Mirrors the +/// HTTPRequest type used by the Java reference implementation so signing +/// implementations like SigV4 can operate on method, url, headers, and body +/// as a single value. +struct ICEBERG_REST_EXPORT HTTPRequest { + HttpMethod method = HttpMethod::kGet; + std::string url; + std::unordered_map headers; + std::string body; +}; + /// \brief An authentication session that can authenticate outgoing HTTP requests. class ICEBERG_REST_EXPORT AuthSession { public: virtual ~AuthSession() = default; - /// \brief Authenticate the given request headers. + /// \brief Authenticate an outgoing HTTP request. /// - /// This method adds authentication information (e.g., Authorization header) - /// to the provided headers map. The implementation should be idempotent. + /// Returns a new request with authentication information (e.g., an + /// Authorization header) added. Implementations must be idempotent and must + /// not mutate the input request. /// - /// \param[in,out] headers The headers map to add authentication information to. - /// \return Status indicating success or one of the following errors: + /// \param request The request to authenticate. + /// \return The authenticated request on success, or one of: /// - AuthenticationFailed: General authentication failure (invalid credentials, /// etc.) /// - TokenExpired: Authentication token has expired and needs refresh /// - NotAuthorized: Not authenticated (401) /// - IOError: Network or connection errors when reaching auth server /// - RestError: HTTP errors from authentication service - virtual Status Authenticate(std::unordered_map& headers) = 0; + virtual Result Authenticate(const HTTPRequest& request) = 0; /// \brief Close the session and release any resources. /// diff --git a/src/iceberg/catalog/rest/auth/sigv4_auth_manager.cc b/src/iceberg/catalog/rest/auth/sigv4_auth_manager.cc new file mode 100644 index 000000000..2e7992772 --- /dev/null +++ b/src/iceberg/catalog/rest/auth/sigv4_auth_manager.cc @@ -0,0 +1,320 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "iceberg/catalog/rest/auth/sigv4_auth_manager.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "iceberg/catalog/rest/auth/auth_manager_internal.h" +#include "iceberg/catalog/rest/auth/auth_managers.h" +#include "iceberg/catalog/rest/auth/auth_properties.h" +#include "iceberg/catalog/rest/endpoint.h" +#include "iceberg/util/macros.h" +#include "iceberg/util/string_util.h" + +namespace iceberg::rest::auth { + +namespace { + +/// \brief Ensures AWS SDK is initialized exactly once per process. +/// ShutdownAPI is intentionally never called (leak-by-design) to avoid +/// static destruction order issues with objects that may outlive shutdown. +class AwsSdkGuard { + public: + static void EnsureInitialized() { + static AwsSdkGuard instance; + (void)instance; + } + + private: + AwsSdkGuard() { + Aws::SDKOptions options; + Aws::InitAPI(options); + } +}; + +Aws::Http::HttpMethod ToAwsMethod(HttpMethod method) { + switch (method) { + case HttpMethod::kGet: + return Aws::Http::HttpMethod::HTTP_GET; + case HttpMethod::kPost: + return Aws::Http::HttpMethod::HTTP_POST; + case HttpMethod::kPut: + return Aws::Http::HttpMethod::HTTP_PUT; + case HttpMethod::kDelete: + return Aws::Http::HttpMethod::HTTP_DELETE; + case HttpMethod::kHead: + return Aws::Http::HttpMethod::HTTP_HEAD; + } + return Aws::Http::HttpMethod::HTTP_GET; +} + +std::unordered_map MergeProperties( + const std::unordered_map& base, + const std::unordered_map& overrides) { + auto merged = base; + for (const auto& [key, value] : overrides) { + merged.insert_or_assign(key, value); + } + return merged; +} + +/// Matches Java RESTSigV4AuthSession: canonical headers carry +/// Base64(SHA256(body)), canonical request trailer uses hex. +class RestSigV4Signer : public Aws::Client::AWSAuthV4Signer { + public: + RestSigV4Signer(const std::shared_ptr& creds, + const char* service_name, const Aws::String& region) + : Aws::Client::AWSAuthV4Signer(creds, service_name, region, + PayloadSigningPolicy::Always, + /*urlEscapePath=*/false) { + // Skip the signer's hex overwrite of x-amz-content-sha256 so canonical + // headers see the caller's Base64; ComputePayloadHash still feeds hex + // into the canonical request trailer. + m_includeSha256HashHeader = false; + } +}; + +} // namespace + +// ---- SigV4AuthSession ---- + +SigV4AuthSession::SigV4AuthSession( + std::shared_ptr delegate, std::string signing_region, + std::string signing_name, + std::shared_ptr credentials_provider, + std::unordered_map effective_properties) + : delegate_(std::move(delegate)), + signing_region_(std::move(signing_region)), + signing_name_(std::move(signing_name)), + credentials_provider_(std::move(credentials_provider)), + signer_(std::make_unique( + credentials_provider_, signing_name_.c_str(), signing_region_.c_str())), + effective_properties_(std::move(effective_properties)) {} + +SigV4AuthSession::~SigV4AuthSession() = default; + +Result SigV4AuthSession::Authenticate(const HTTPRequest& request) { + ICEBERG_ASSIGN_OR_RAISE(auto delegate_request, delegate_->Authenticate(request)); + const auto& original_headers = delegate_request.headers; + + std::unordered_map signing_headers; + for (const auto& [name, value] : original_headers) { + if (StringUtils::EqualsIgnoreCase(name, "Authorization")) { + signing_headers[std::string(kRelocatedHeaderPrefix) + name] = value; + } else { + signing_headers[name] = value; + } + } + + Aws::Http::URI aws_uri(delegate_request.url.c_str()); + auto aws_request = std::make_shared( + aws_uri, ToAwsMethod(delegate_request.method)); + for (const auto& [name, value] : signing_headers) { + aws_request->SetHeaderValue(Aws::String(name.c_str()), Aws::String(value.c_str())); + } + + // Empty body: hex EMPTY_BODY_SHA256 (Java parity workaround for the signer + // computing an invalid checksum on empty bodies). Non-empty: Base64. + if (delegate_request.body.empty()) { + aws_request->SetHeaderValue("x-amz-content-sha256", Aws::String(kEmptyBodySha256)); + } else { + auto body_stream = + Aws::MakeShared("SigV4Body", delegate_request.body); + aws_request->AddContentBody(body_stream); + auto sha256 = Aws::Utils::HashingUtils::CalculateSHA256( + Aws::String(delegate_request.body.data(), delegate_request.body.size())); + aws_request->SetHeaderValue("x-amz-content-sha256", + Aws::Utils::HashingUtils::Base64Encode(sha256)); + } + + if (!signer_->SignRequest(*aws_request)) { + return std::unexpected(Error{.kind = ErrorKind::kAuthenticationFailed, + .message = "SigV4 signing failed"}); + } + + HTTPRequest signed_request{.method = delegate_request.method, + .url = std::move(delegate_request.url), + .headers = {}, + .body = std::move(delegate_request.body)}; + for (const auto& [aws_name, aws_value] : aws_request->GetHeaders()) { + std::string name(aws_name.c_str(), aws_name.size()); + std::string value(aws_value.c_str(), aws_value.size()); + for (const auto& [orig_name, orig_value] : original_headers) { + if (StringUtils::EqualsIgnoreCase(orig_name, name) && orig_value != value) { + signed_request.headers[std::string(kRelocatedHeaderPrefix) + orig_name] = + orig_value; + break; + } + } + signed_request.headers[std::move(name)] = std::move(value); + } + + return signed_request; +} + +Status SigV4AuthSession::Close() { return delegate_->Close(); } + +// ---- SigV4AuthManager ---- + +SigV4AuthManager::SigV4AuthManager(std::unique_ptr delegate) + : delegate_(std::move(delegate)) {} + +SigV4AuthManager::~SigV4AuthManager() = default; + +Result> SigV4AuthManager::InitSession( + HttpClient& init_client, + const std::unordered_map& properties) { + AwsSdkGuard::EnsureInitialized(); + ICEBERG_ASSIGN_OR_RAISE(auto delegate_session, + delegate_->InitSession(init_client, properties)); + return WrapSession(std::move(delegate_session), properties); +} + +Result> SigV4AuthManager::CatalogSession( + HttpClient& shared_client, + const std::unordered_map& properties) { + AwsSdkGuard::EnsureInitialized(); + ICEBERG_ASSIGN_OR_RAISE(auto delegate_session, + delegate_->CatalogSession(shared_client, properties)); + return WrapSession(std::move(delegate_session), properties); +} + +Result> SigV4AuthManager::ContextualSession( + const std::unordered_map& context, + std::shared_ptr parent) { + auto sigv4_parent = std::dynamic_pointer_cast(std::move(parent)); + ICEBERG_PRECHECK(sigv4_parent != nullptr, + "SigV4AuthManager parent must be a SigV4AuthSession"); + + ICEBERG_ASSIGN_OR_RAISE(auto delegate_session, delegate_->ContextualSession( + context, sigv4_parent->delegate())); + + auto merged = MergeProperties(sigv4_parent->effective_properties(), context); + return WrapSession(std::move(delegate_session), std::move(merged)); +} + +Result> SigV4AuthManager::TableSession( + const TableIdentifier& table, + const std::unordered_map& properties, + std::shared_ptr parent) { + auto sigv4_parent = std::dynamic_pointer_cast(std::move(parent)); + ICEBERG_PRECHECK(sigv4_parent != nullptr, + "SigV4AuthManager parent must be a SigV4AuthSession"); + + ICEBERG_ASSIGN_OR_RAISE( + auto delegate_session, + delegate_->TableSession(table, properties, sigv4_parent->delegate())); + + auto merged = MergeProperties(sigv4_parent->effective_properties(), properties); + return WrapSession(std::move(delegate_session), std::move(merged)); +} + +Status SigV4AuthManager::Close() { return delegate_->Close(); } + +// TODO(sigv4): support loading a custom AWSCredentialsProvider via a class +// name property, matching Java's AwsProperties.restCredentialsProvider(). +Result> +SigV4AuthManager::MakeCredentialsProvider( + const std::unordered_map& properties) { + auto access_key_it = properties.find(AuthProperties::kSigV4AccessKeyId); + auto secret_key_it = properties.find(AuthProperties::kSigV4SecretAccessKey); + bool has_ak = access_key_it != properties.end() && !access_key_it->second.empty(); + bool has_sk = secret_key_it != properties.end() && !secret_key_it->second.empty(); + + ICEBERG_PRECHECK( + has_ak == has_sk, "Both '{}' and '{}' must be set together, or neither", + AuthProperties::kSigV4AccessKeyId, AuthProperties::kSigV4SecretAccessKey); + + if (has_ak) { + Aws::Auth::AWSCredentials credentials(access_key_it->second.c_str(), + secret_key_it->second.c_str()); + auto session_token_it = properties.find(AuthProperties::kSigV4SessionToken); + if (session_token_it != properties.end() && !session_token_it->second.empty()) { + credentials.SetSessionToken(session_token_it->second.c_str()); + } + return std::make_shared(credentials); + } + + return std::make_shared(); +} + +std::string SigV4AuthManager::ResolveSigningRegion( + const std::unordered_map& properties) { + if (auto it = properties.find(AuthProperties::kSigV4SigningRegion); + it != properties.end() && !it->second.empty()) { + return it->second; + } + // Delegates the full resolution chain (AWS_DEFAULT_REGION / AWS_REGION env, + // ~/.aws/config profile, EC2/ECS IMDS, fallback us-east-1) to the AWS SDK. + // Set AWS_EC2_METADATA_DISABLED=true to skip IMDS on non-EC2 hosts. + return {Aws::Client::ClientConfiguration().region.c_str()}; +} + +std::string SigV4AuthManager::ResolveSigningName( + const std::unordered_map& properties) { + if (auto it = properties.find(AuthProperties::kSigV4SigningName); + it != properties.end() && !it->second.empty()) { + return it->second; + } + return AuthProperties::kSigV4SigningNameDefault; +} + +Result> SigV4AuthManager::WrapSession( + std::shared_ptr delegate_session, + std::unordered_map properties) { + auto region = ResolveSigningRegion(properties); + auto service = ResolveSigningName(properties); + ICEBERG_ASSIGN_OR_RAISE(auto credentials, MakeCredentialsProvider(properties)); + return std::make_shared( + std::move(delegate_session), std::move(region), std::move(service), + std::move(credentials), std::move(properties)); +} + +Result> MakeSigV4AuthManager( + std::string_view name, + const std::unordered_map& properties) { + // Default to OAuth2 when delegate type is not specified. + std::string delegate_type = AuthProperties::kAuthTypeOAuth2; + if (auto it = properties.find(AuthProperties::kSigV4DelegateAuthType); + it != properties.end() && !it->second.empty()) { + delegate_type = StringUtils::ToLower(it->second); + } + + // Prevent circular delegation (sigv4 -> sigv4 -> ...). + ICEBERG_PRECHECK(delegate_type != AuthProperties::kAuthTypeSigV4, + "Cannot delegate a SigV4 auth manager to another SigV4 auth " + "manager (delegate_type='{}')", + delegate_type); + + auto delegate_props = properties; + delegate_props[AuthProperties::kAuthType] = delegate_type; + ICEBERG_ASSIGN_OR_RAISE(auto delegate, AuthManagers::Load(name, delegate_props)); + return std::make_unique(std::move(delegate)); +} + +} // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/auth/sigv4_auth_manager.h b/src/iceberg/catalog/rest/auth/sigv4_auth_manager.h new file mode 100644 index 000000000..4e173a284 --- /dev/null +++ b/src/iceberg/catalog/rest/auth/sigv4_auth_manager.h @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#pragma once + +#include +#include +#include + +#include "iceberg/catalog/rest/auth/auth_manager.h" +#include "iceberg/catalog/rest/auth/auth_session.h" +#include "iceberg/catalog/rest/iceberg_rest_export.h" +#include "iceberg/result.h" + +namespace Aws::Auth { +class AWSCredentialsProvider; +} // namespace Aws::Auth + +namespace Aws::Client { +class AWSAuthV4Signer; +} // namespace Aws::Client + +namespace iceberg::rest::auth { + +/// \brief An AuthSession that signs requests with AWS SigV4. +/// +/// The request is first authenticated by the delegate AuthSession (e.g., OAuth2), +/// then signed with SigV4. In case of conflicting headers, the Authorization header +/// set by the delegate is relocated with an "Original-" prefix, then included in +/// the canonical headers to sign. +/// +/// See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv.html +/// +/// Thread safety: Authenticate() is thread-safe as long as the delegate +/// session is. +class ICEBERG_REST_EXPORT SigV4AuthSession : public AuthSession { + public: + /// SHA-256 hash of empty string, used for requests with no body. + static constexpr std::string_view kEmptyBodySha256 = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + /// Prefix prepended to relocated headers that conflict with SigV4-signed headers. + static constexpr std::string_view kRelocatedHeaderPrefix = "Original-"; + + SigV4AuthSession( + std::shared_ptr delegate, std::string signing_region, + std::string signing_name, + std::shared_ptr credentials_provider, + std::unordered_map effective_properties); + + ~SigV4AuthSession() override; + + Result Authenticate(const HTTPRequest& request) override; + + Status Close() override; + + const std::shared_ptr& delegate() const { return delegate_; } + + /// Merged properties this session was built from. Child sessions inherit + /// from this (not the catalog's) so contextual overrides propagate into + /// table sessions. + const std::unordered_map& effective_properties() const { + return effective_properties_; + } + + private: + std::shared_ptr delegate_; + std::string signing_region_; + std::string signing_name_; + std::shared_ptr credentials_provider_; + std::unique_ptr signer_; + std::unordered_map effective_properties_; +}; + +/// \brief An AuthManager that produces SigV4AuthSession instances. +/// +/// Wraps a delegate AuthManager to handle double authentication (e.g., OAuth2 + SigV4). +class ICEBERG_REST_EXPORT SigV4AuthManager : public AuthManager { + public: + explicit SigV4AuthManager(std::unique_ptr delegate); + ~SigV4AuthManager() override; + + Result> InitSession( + HttpClient& init_client, + const std::unordered_map& properties) override; + + Result> CatalogSession( + HttpClient& shared_client, + const std::unordered_map& properties) override; + + Result> ContextualSession( + const std::unordered_map& context, + std::shared_ptr parent) override; + + Result> TableSession( + const TableIdentifier& table, + const std::unordered_map& properties, + std::shared_ptr parent) override; + + Status Close() override; + + private: + static Result> + MakeCredentialsProvider(const std::unordered_map& properties); + static std::string ResolveSigningRegion( + const std::unordered_map& properties); + static std::string ResolveSigningName( + const std::unordered_map& properties); + Result> WrapSession( + std::shared_ptr delegate_session, + std::unordered_map properties); + + std::unique_ptr delegate_; +}; + +} // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/http_client.cc b/src/iceberg/catalog/rest/http_client.cc index 2e383b0ae..2872f9069 100644 --- a/src/iceberg/catalog/rest/http_client.cc +++ b/src/iceberg/catalog/rest/http_client.cc @@ -19,6 +19,8 @@ #include "iceberg/catalog/rest/http_client.h" +#include + #include #include @@ -68,27 +70,37 @@ namespace { /// \brief Default error type for unparseable REST responses. constexpr std::string_view kRestExceptionType = "RESTException"; -/// \brief Prepare headers for an HTTP request. -Result BuildHeaders( - const std::unordered_map& request_headers, +/// \brief Merge default headers with per-request headers (per-request wins). +std::unordered_map MergeHeaders( const std::unordered_map& default_headers, - auth::AuthSession& session) { - std::unordered_map headers(default_headers); + const std::unordered_map& request_headers) { + std::unordered_map merged(default_headers); for (const auto& [key, val] : request_headers) { - headers.insert_or_assign(key, val); + merged.insert_or_assign(key, val); } - ICEBERG_RETURN_UNEXPECTED(session.Authenticate(headers)); - return cpr::Header(headers.begin(), headers.end()); + return merged; +} + +cpr::Header ToCprHeader(const auth::HTTPRequest& request) { + return {request.headers.begin(), request.headers.end()}; } -/// \brief Converts a map of string key-value pairs to cpr::Parameters. -cpr::Parameters GetParameters( +/// \brief Append URL-encoded query parameters to a URL, sorted by key. +Result AppendQueryString( + const std::string& base_url, const std::unordered_map& params) { - cpr::Parameters cpr_params; - for (const auto& [key, val] : params) { - cpr_params.Add({key, val}); + if (params.empty()) return base_url; + std::map sorted(params.begin(), params.end()); + std::string url = base_url + "?"; + bool first = true; + for (const auto& [k, v] : sorted) { + if (!first) url += "&"; + ICEBERG_ASSIGN_OR_RAISE(auto ek, EncodeString(k)); + ICEBERG_ASSIGN_OR_RAISE(auto ev, EncodeString(v)); + url += ek + "=" + ev; + first = false; } - return cpr_params; + return url; } /// \brief Checks if the HTTP status code indicates a successful response. @@ -149,10 +161,15 @@ Result HttpClient::Get( const std::string& path, const std::unordered_map& params, const std::unordered_map& headers, const ErrorHandler& error_handler, auth::AuthSession& session) { - ICEBERG_ASSIGN_OR_RAISE(auto all_headers, - BuildHeaders(headers, default_headers_, session)); - cpr::Response response = - cpr::Get(cpr::Url{path}, GetParameters(params), all_headers, *connection_pool_); + ICEBERG_ASSIGN_OR_RAISE(auto url, AppendQueryString(path, params)); + ICEBERG_ASSIGN_OR_RAISE( + auto authenticated, + session.Authenticate({.method = HttpMethod::kGet, + .url = std::move(url), + .headers = MergeHeaders(default_headers_, headers), + .body = ""})); + cpr::Response response = cpr::Get(cpr::Url{authenticated.url}, + ToCprHeader(authenticated), *connection_pool_); ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); HttpResponse http_response; @@ -164,10 +181,15 @@ Result HttpClient::Post( const std::string& path, const std::string& body, const std::unordered_map& headers, const ErrorHandler& error_handler, auth::AuthSession& session) { - ICEBERG_ASSIGN_OR_RAISE(auto all_headers, - BuildHeaders(headers, default_headers_, session)); + ICEBERG_ASSIGN_OR_RAISE( + auto authenticated, + session.Authenticate({.method = HttpMethod::kPost, + .url = path, + .headers = MergeHeaders(default_headers_, headers), + .body = body})); cpr::Response response = - cpr::Post(cpr::Url{path}, cpr::Body{body}, all_headers, *connection_pool_); + cpr::Post(cpr::Url{authenticated.url}, cpr::Body{authenticated.body}, + ToCprHeader(authenticated), *connection_pool_); ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); HttpResponse http_response; @@ -182,16 +204,23 @@ Result HttpClient::PostForm( const ErrorHandler& error_handler, auth::AuthSession& session) { std::unordered_map form_headers(headers); form_headers.insert_or_assign(kHeaderContentType, kMimeTypeFormUrlEncoded); - ICEBERG_ASSIGN_OR_RAISE(auto all_headers, - BuildHeaders(form_headers, default_headers_, session)); std::vector pair_list; pair_list.reserve(form_data.size()); for (const auto& [key, val] : form_data) { pair_list.emplace_back(key, val); } + // Sign the exact bytes cpr will put on the wire. + std::string encoded_body = + cpr::Payload(pair_list.begin(), pair_list.end()).GetContent(); + ICEBERG_ASSIGN_OR_RAISE( + auto authenticated, + session.Authenticate({.method = HttpMethod::kPost, + .url = path, + .headers = MergeHeaders(default_headers_, form_headers), + .body = std::move(encoded_body)})); cpr::Response response = - cpr::Post(cpr::Url{path}, cpr::Payload(pair_list.begin(), pair_list.end()), - all_headers, *connection_pool_); + cpr::Post(cpr::Url{authenticated.url}, cpr::Body{authenticated.body}, + ToCprHeader(authenticated), *connection_pool_); ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); HttpResponse http_response; @@ -202,9 +231,14 @@ Result HttpClient::PostForm( Result HttpClient::Head( const std::string& path, const std::unordered_map& headers, const ErrorHandler& error_handler, auth::AuthSession& session) { - ICEBERG_ASSIGN_OR_RAISE(auto all_headers, - BuildHeaders(headers, default_headers_, session)); - cpr::Response response = cpr::Head(cpr::Url{path}, all_headers, *connection_pool_); + ICEBERG_ASSIGN_OR_RAISE( + auto authenticated, + session.Authenticate({.method = HttpMethod::kHead, + .url = path, + .headers = MergeHeaders(default_headers_, headers), + .body = ""})); + cpr::Response response = cpr::Head(cpr::Url{authenticated.url}, + ToCprHeader(authenticated), *connection_pool_); ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); HttpResponse http_response; @@ -216,10 +250,15 @@ Result HttpClient::Delete( const std::string& path, const std::unordered_map& params, const std::unordered_map& headers, const ErrorHandler& error_handler, auth::AuthSession& session) { - ICEBERG_ASSIGN_OR_RAISE(auto all_headers, - BuildHeaders(headers, default_headers_, session)); - cpr::Response response = - cpr::Delete(cpr::Url{path}, GetParameters(params), all_headers, *connection_pool_); + ICEBERG_ASSIGN_OR_RAISE(auto url, AppendQueryString(path, params)); + ICEBERG_ASSIGN_OR_RAISE( + auto authenticated, + session.Authenticate({.method = HttpMethod::kDelete, + .url = std::move(url), + .headers = MergeHeaders(default_headers_, headers), + .body = ""})); + cpr::Response response = cpr::Delete(cpr::Url{authenticated.url}, + ToCprHeader(authenticated), *connection_pool_); ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); HttpResponse http_response; diff --git a/src/iceberg/catalog/rest/meson.build b/src/iceberg/catalog/rest/meson.build index ef2500456..dd518b2f6 100644 --- a/src/iceberg/catalog/rest/meson.build +++ b/src/iceberg/catalog/rest/meson.build @@ -40,16 +40,26 @@ cpr_needs_static = ( cpr_dep = dependency('cpr', static: cpr_needs_static) iceberg_rest_build_deps = [iceberg_dep, cpr_dep] +iceberg_rest_compile_defs = [] + +sigv4_opt = get_option('sigv4') +aws_sdk_core_dep = dependency('aws-cpp-sdk-core', required: sigv4_opt) +if aws_sdk_core_dep.found() + iceberg_rest_sources += files('auth/sigv4_auth_manager.cc') + iceberg_rest_build_deps += aws_sdk_core_dep + iceberg_rest_compile_defs += '-DICEBERG_BUILD_SIGV4' +endif + iceberg_rest_lib = library( 'iceberg_rest', sources: iceberg_rest_sources, dependencies: iceberg_rest_build_deps, gnu_symbol_visibility: 'hidden', - cpp_shared_args: ['-DICEBERG_REST_EXPORTING'], - cpp_static_args: ['-DICEBERG_REST_STATIC'], + cpp_shared_args: ['-DICEBERG_REST_EXPORTING'] + iceberg_rest_compile_defs, + cpp_static_args: ['-DICEBERG_REST_STATIC'] + iceberg_rest_compile_defs, ) -iceberg_rest_compile_args = [] +iceberg_rest_compile_args = iceberg_rest_compile_defs if get_option('default_library') == 'static' iceberg_rest_compile_args += ['-DICEBERG_REST_STATIC'] endif @@ -78,13 +88,14 @@ install_headers( subdir: 'iceberg/catalog/rest', ) -install_headers( - [ - 'auth/auth_manager.h', - 'auth/auth_managers.h', - 'auth/auth_properties.h', - 'auth/auth_session.h', - 'auth/oauth2_util.h', - ], - subdir: 'iceberg/catalog/rest/auth', -) +iceberg_rest_auth_headers = [ + 'auth/auth_manager.h', + 'auth/auth_managers.h', + 'auth/auth_properties.h', + 'auth/auth_session.h', + 'auth/oauth2_util.h', +] +if aws_sdk_core_dep.found() + iceberg_rest_auth_headers += ['auth/sigv4_auth_manager.h'] +endif +install_headers(iceberg_rest_auth_headers, subdir: 'iceberg/catalog/rest/auth') diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt index 2dc90da64..c29a8b20e 100644 --- a/src/iceberg/test/CMakeLists.txt +++ b/src/iceberg/test/CMakeLists.txt @@ -230,6 +230,11 @@ if(ICEBERG_BUILD_REST) rest_json_serde_test.cc rest_util_test.cc) + if(ICEBERG_BUILD_SIGV4) + add_rest_iceberg_test(sigv4_auth_test SOURCES sigv4_auth_test.cc) + target_link_libraries(sigv4_auth_test PRIVATE aws-cpp-sdk-core) + endif() + if(ICEBERG_BUILD_REST_INTEGRATION_TESTS) add_rest_iceberg_test(rest_catalog_integration_test SOURCES diff --git a/src/iceberg/test/auth_manager_test.cc b/src/iceberg/test/auth_manager_test.cc index bd06fee3f..04ad2bd1d 100644 --- a/src/iceberg/test/auth_manager_test.cc +++ b/src/iceberg/test/auth_manager_test.cc @@ -63,9 +63,9 @@ TEST_F(AuthManagerTest, LoadNoopAuthManagerExplicit) { auto session_result = manager_result.value()->CatalogSession(client_, properties); ASSERT_THAT(session_result, IsOk()); - std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); - EXPECT_TRUE(headers.empty()); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()); + EXPECT_TRUE(auth_result.value().headers.empty()); } // Verifies that NoopAuthManager is inferred when no auth properties are set @@ -107,10 +107,10 @@ TEST_F(AuthManagerTest, LoadBasicAuthManager) { auto session_result = manager_result.value()->CatalogSession(client_, properties); ASSERT_THAT(session_result, IsOk()); - std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()); // base64("admin:secret") == "YWRtaW46c2VjcmV0" - EXPECT_EQ(headers["Authorization"], "Basic YWRtaW46c2VjcmV0"); + EXPECT_EQ(auth_result.value().headers["Authorization"], "Basic YWRtaW46c2VjcmV0"); } // Verifies BasicAuthManager is case-insensitive for auth type @@ -126,10 +126,10 @@ TEST_F(AuthManagerTest, BasicAuthTypeCaseInsensitive) { auto session_result = manager_result.value()->CatalogSession(client_, properties); ASSERT_THAT(session_result, IsOk()) << "Failed for auth type: " << auth_type; - std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()) << "Failed for auth type: " << auth_type; // base64("user:pass") == "dXNlcjpwYXNz" - EXPECT_EQ(headers["Authorization"], "Basic dXNlcjpwYXNz"); + EXPECT_EQ(auth_result.value().headers["Authorization"], "Basic dXNlcjpwYXNz"); } } @@ -172,10 +172,11 @@ TEST_F(AuthManagerTest, BasicAuthSpecialCharacters) { auto session_result = manager_result.value()->CatalogSession(client_, properties); ASSERT_THAT(session_result, IsOk()); - std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()); // base64("user@domain.com:p@ss:w0rd!") == "dXNlckBkb21haW4uY29tOnBAc3M6dzByZCE=" - EXPECT_EQ(headers["Authorization"], "Basic dXNlckBkb21haW4uY29tOnBAc3M6dzByZCE="); + EXPECT_EQ(auth_result.value().headers["Authorization"], + "Basic dXNlckBkb21haW4uY29tOnBAc3M6dzByZCE="); } // Verifies custom auth manager registration @@ -204,9 +205,9 @@ TEST_F(AuthManagerTest, RegisterCustomAuthManager) { auto session_result = manager_result.value()->CatalogSession(client_, properties); ASSERT_THAT(session_result, IsOk()); - std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); - EXPECT_EQ(headers["X-Custom-Auth"], "custom-value"); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()); + EXPECT_EQ(auth_result.value().headers["X-Custom-Auth"], "custom-value"); } // Verifies OAuth2 with static token @@ -222,9 +223,9 @@ TEST_F(AuthManagerTest, OAuth2StaticToken) { auto session_result = manager_result.value()->CatalogSession(client_, properties); ASSERT_THAT(session_result, IsOk()); - std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); - EXPECT_EQ(headers["Authorization"], "Bearer my-static-token"); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()); + EXPECT_EQ(auth_result.value().headers["Authorization"], "Bearer my-static-token"); } // Verifies OAuth2 type is inferred from token property @@ -239,9 +240,9 @@ TEST_F(AuthManagerTest, OAuth2InferredFromToken) { auto session_result = manager_result.value()->CatalogSession(client_, properties); ASSERT_THAT(session_result, IsOk()); - std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); - EXPECT_EQ(headers["Authorization"], "Bearer inferred-token"); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()); + EXPECT_EQ(auth_result.value().headers["Authorization"], "Bearer inferred-token"); } // Verifies OAuth2 returns unauthenticated session when neither token nor credential is @@ -258,9 +259,10 @@ TEST_F(AuthManagerTest, OAuth2MissingCredentials) { ASSERT_THAT(session_result, IsOk()); // Session should have no auth headers - std::unordered_map headers; - ASSERT_TRUE(session_result.value()->Authenticate(headers).has_value()); - EXPECT_EQ(headers.find("Authorization"), headers.end()); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_TRUE(auth_result.has_value()); + EXPECT_EQ(auth_result.value().headers.find("Authorization"), + auth_result.value().headers.end()); } // Verifies that when both token and credential are provided, token takes priority @@ -279,9 +281,9 @@ TEST_F(AuthManagerTest, OAuth2TokenTakesPriorityOverCredential) { auto session_result = manager_result.value()->CatalogSession(client_, properties); ASSERT_THAT(session_result, IsOk()); - std::unordered_map headers; - ASSERT_THAT(session_result.value()->Authenticate(headers), IsOk()); - EXPECT_EQ(headers["Authorization"], "Bearer my-static-token"); + auto auth_result = session_result.value()->Authenticate({}); + ASSERT_THAT(auth_result, IsOk()); + EXPECT_EQ(auth_result.value().headers["Authorization"], "Bearer my-static-token"); } // Verifies OAuthTokenResponse JSON parsing diff --git a/src/iceberg/test/meson.build b/src/iceberg/test/meson.build index 9a8da9dd5..ac93ff138 100644 --- a/src/iceberg/test/meson.build +++ b/src/iceberg/test/meson.build @@ -116,6 +116,14 @@ if get_option('rest').enabled() 'dependencies': [iceberg_rest_dep], }, } + if aws_sdk_core_dep.found() + iceberg_tests += { + 'sigv4_auth_test': { + 'sources': files('sigv4_auth_test.cc'), + 'dependencies': [iceberg_rest_dep, aws_sdk_core_dep], + }, + } + endif if get_option('rest_integration_test').enabled() if host_machine.system() == 'windows' warning('Cannot build rest integration test on Windows, skipping.') diff --git a/src/iceberg/test/sigv4_auth_test.cc b/src/iceberg/test/sigv4_auth_test.cc new file mode 100644 index 000000000..fd52e8dad --- /dev/null +++ b/src/iceberg/test/sigv4_auth_test.cc @@ -0,0 +1,536 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include +#include + +#include +#include +#include + +#include "iceberg/catalog/rest/auth/auth_managers.h" +#include "iceberg/catalog/rest/auth/auth_properties.h" +#include "iceberg/catalog/rest/auth/auth_session.h" +#include "iceberg/catalog/rest/auth/sigv4_auth_manager.h" +#include "iceberg/catalog/rest/http_client.h" +#include "iceberg/table_identifier.h" +#include "iceberg/test/matchers.h" + +namespace iceberg::rest::auth { + +class SigV4AuthTest : public ::testing::Test { + protected: + static void SetUpTestSuite() { + static bool initialized = false; + if (!initialized) { + Aws::SDKOptions options; + Aws::InitAPI(options); + initialized = true; + } + } + + HttpClient client_{{}}; + + std::unordered_map MakeSigV4Properties() { + return { + {AuthProperties::kAuthType, "sigv4"}, + {AuthProperties::kSigV4SigningRegion, "us-east-1"}, + {AuthProperties::kSigV4SigningName, "execute-api"}, + {AuthProperties::kSigV4AccessKeyId, "AKIAIOSFODNN7EXAMPLE"}, + {AuthProperties::kSigV4SecretAccessKey, + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}, + }; + } +}; + +TEST_F(SigV4AuthTest, LoadSigV4AuthManager) { + auto properties = MakeSigV4Properties(); + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); +} + +TEST_F(SigV4AuthTest, CatalogSessionProducesSession) { + auto properties = MakeSigV4Properties(); + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto session_result = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(session_result, IsOk()); +} + +TEST_F(SigV4AuthTest, AuthenticateAddsAuthorizationHeader) { + auto properties = MakeSigV4Properties(); + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto session_result = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(session_result, IsOk()); + + HTTPRequest request{.method = HttpMethod::kGet, .url = "https://example.com/v1/config"}; + auto auth_result = session_result.value()->Authenticate(request); + ASSERT_THAT(auth_result, IsOk()); + const auto& headers = auth_result.value().headers; + + EXPECT_NE(headers.find("authorization"), headers.end()); + EXPECT_TRUE(headers.at("authorization").starts_with("AWS4-HMAC-SHA256")); + EXPECT_NE(headers.find("x-amz-date"), headers.end()); +} + +TEST_F(SigV4AuthTest, AuthenticateWithPostBody) { + auto properties = MakeSigV4Properties(); + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto session_result = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(session_result, IsOk()); + + HTTPRequest request{.method = HttpMethod::kPost, + .url = "https://example.com/v1/namespaces", + .headers = {{"Content-Type", "application/json"}}, + .body = R"({"namespace":["ns1"]})"}; + auto auth_result = session_result.value()->Authenticate(request); + ASSERT_THAT(auth_result, IsOk()); + const auto& headers = auth_result.value().headers; + + EXPECT_NE(headers.find("authorization"), headers.end()); + EXPECT_TRUE(headers.at("authorization").starts_with("AWS4-HMAC-SHA256")); +} + +TEST_F(SigV4AuthTest, DelegateAuthorizationHeaderRelocated) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kToken.key()] = "my-oauth-token"; + properties[AuthProperties::kSigV4DelegateAuthType] = "oauth2"; + + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto session_result = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(session_result, IsOk()); + + HTTPRequest request{.method = HttpMethod::kGet, .url = "https://example.com/v1/config"}; + auto auth_result = session_result.value()->Authenticate(request); + ASSERT_THAT(auth_result, IsOk()); + const auto& headers = auth_result.value().headers; + + EXPECT_NE(headers.find("authorization"), headers.end()); + EXPECT_TRUE(headers.at("authorization").starts_with("AWS4-HMAC-SHA256")); + EXPECT_NE(headers.find("original-authorization"), headers.end()); + EXPECT_EQ(headers.at("original-authorization"), "Bearer my-oauth-token"); +} + +TEST_F(SigV4AuthTest, AuthenticateWithSessionToken) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kSigV4SessionToken] = "FwoGZXIvYXdzEBYaDHqa0"; + + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto session_result = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(session_result, IsOk()); + + HTTPRequest request{.method = HttpMethod::kGet, .url = "https://example.com/v1/config"}; + auto auth_result = session_result.value()->Authenticate(request); + ASSERT_THAT(auth_result, IsOk()); + const auto& headers = auth_result.value().headers; + + EXPECT_NE(headers.find("authorization"), headers.end()); + EXPECT_NE(headers.find("x-amz-security-token"), headers.end()); + EXPECT_EQ(headers.at("x-amz-security-token"), "FwoGZXIvYXdzEBYaDHqa0"); +} + +TEST_F(SigV4AuthTest, CustomSigningNameAndRegion) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kSigV4SigningRegion] = "eu-west-1"; + properties[AuthProperties::kSigV4SigningName] = "custom-service"; + + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto session_result = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(session_result, IsOk()); + + HTTPRequest request{.method = HttpMethod::kGet, .url = "https://example.com/v1/config"}; + auto auth_result = session_result.value()->Authenticate(request); + ASSERT_THAT(auth_result, IsOk()); + const auto& headers = auth_result.value().headers; + + auto auth_it = headers.find("authorization"); + ASSERT_NE(auth_it, headers.end()); + EXPECT_TRUE(auth_it->second.find("eu-west-1") != std::string::npos); + EXPECT_TRUE(auth_it->second.find("custom-service") != std::string::npos); +} + +TEST_F(SigV4AuthTest, AuthTypeCaseInsensitive) { + for (const auto& auth_type : {"SIGV4", "SigV4", "sigV4"}) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kAuthType] = auth_type; + EXPECT_THAT(AuthManagers::Load("test-catalog", properties), IsOk()) + << "Failed for auth type: " << auth_type; + } +} + +TEST_F(SigV4AuthTest, DelegateDefaultsToOAuth2NoAuth) { + auto properties = MakeSigV4Properties(); + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto session_result = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(session_result, IsOk()); + + HTTPRequest request{.method = HttpMethod::kGet, .url = "https://example.com/v1/config"}; + auto auth_result = session_result.value()->Authenticate(request); + ASSERT_THAT(auth_result, IsOk()); + const auto& headers = auth_result.value().headers; + + EXPECT_EQ(headers.find("original-authorization"), headers.end()); +} + +TEST_F(SigV4AuthTest, TableSessionInheritsProperties) { + auto properties = MakeSigV4Properties(); + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto catalog_session = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(catalog_session, IsOk()); + + iceberg::TableIdentifier table_id{.ns = iceberg::Namespace{{"ns1"}}, .name = "table1"}; + std::unordered_map table_props; + auto table_session = manager_result.value()->TableSession(table_id, table_props, + catalog_session.value()); + ASSERT_THAT(table_session, IsOk()); + + HTTPRequest request{.method = HttpMethod::kGet, + .url = "https://example.com/v1/ns1/tables/table1"}; + auto auth_result = table_session.value()->Authenticate(request); + ASSERT_THAT(auth_result, IsOk()); + EXPECT_NE(auth_result.value().headers.find("authorization"), + auth_result.value().headers.end()); +} + +// ---------- Tests ported from Java TestRESTSigV4AuthSession ---------- + +// Java: authenticateWithoutBody +TEST_F(SigV4AuthTest, AuthenticateWithoutBodyDetailedHeaders) { + auto properties = MakeSigV4Properties(); + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto session_result = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(session_result, IsOk()); + + HTTPRequest request{.method = HttpMethod::kGet, + .url = "http://localhost:8080/path", + .headers = {{"Content-Type", "application/json"}}}; + auto auth_result = session_result.value()->Authenticate(request); + ASSERT_THAT(auth_result, IsOk()); + const auto& headers = auth_result.value().headers; + + // Original header preserved + EXPECT_EQ(headers.at("content-type"), "application/json"); + + // Host header generated by the signer + EXPECT_NE(headers.find("host"), headers.end()); + + // SigV4 headers + auto auth_it = headers.find("authorization"); + ASSERT_NE(auth_it, headers.end()); + EXPECT_TRUE(auth_it->second.starts_with("AWS4-HMAC-SHA256 Credential=")); + + EXPECT_TRUE(auth_it->second.find("content-type") != std::string::npos); + EXPECT_TRUE(auth_it->second.find("host") != std::string::npos); + EXPECT_TRUE(auth_it->second.find("x-amz-content-sha256") != std::string::npos); + EXPECT_TRUE(auth_it->second.find("x-amz-date") != std::string::npos); + + // Empty body SHA256 hash + EXPECT_EQ(headers.at("x-amz-content-sha256"), SigV4AuthSession::kEmptyBodySha256); + + // X-Amz-Date present + EXPECT_NE(headers.find("x-amz-date"), headers.end()); +} + +// Java: authenticateWithBody +TEST_F(SigV4AuthTest, AuthenticateWithBodyDetailedHeaders) { + auto properties = MakeSigV4Properties(); + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto session_result = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(session_result, IsOk()); + + HTTPRequest request{.method = HttpMethod::kPost, + .url = "http://localhost:8080/path", + .headers = {{"Content-Type", "application/json"}}, + .body = R"({"namespace":["ns1"]})"}; + auto auth_result = session_result.value()->Authenticate(request); + ASSERT_THAT(auth_result, IsOk()); + const auto& headers = auth_result.value().headers; + + // SigV4 Authorization header + auto auth_it = headers.find("authorization"); + ASSERT_NE(auth_it, headers.end()); + EXPECT_TRUE(auth_it->second.starts_with("AWS4-HMAC-SHA256 Credential=")); + + // x-amz-content-sha256 should be Base64-encoded body SHA256 (matching Java) + auto sha_it = headers.find("x-amz-content-sha256"); + ASSERT_NE(sha_it, headers.end()); + EXPECT_NE(sha_it->second, SigV4AuthSession::kEmptyBodySha256); + + EXPECT_EQ(sha_it->second.size(), 44) + << "Expected Base64 SHA256, got: " << sha_it->second; +} + +// Java: authenticateConflictingAuthorizationHeader +TEST_F(SigV4AuthTest, ConflictingAuthorizationHeaderIncludedInSignedHeaders) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kToken.key()] = "my-oauth-token"; + properties[AuthProperties::kSigV4DelegateAuthType] = "oauth2"; + + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto session_result = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(session_result, IsOk()); + + HTTPRequest request{.method = HttpMethod::kGet, + .url = "http://localhost:8080/path", + .headers = {{"Content-Type", "application/json"}}}; + auto auth_result = session_result.value()->Authenticate(request); + ASSERT_THAT(auth_result, IsOk()); + const auto& headers = auth_result.value().headers; + + // SigV4 Authorization header + auto auth_it = headers.find("authorization"); + ASSERT_NE(auth_it, headers.end()); + EXPECT_TRUE(auth_it->second.starts_with("AWS4-HMAC-SHA256 Credential=")); + + // Relocated delegate header should be in SignedHeaders + EXPECT_TRUE(auth_it->second.find("original-authorization") != std::string::npos) + << "SignedHeaders should include 'original-authorization', got: " + << auth_it->second; + + // Relocated Authorization present + auto orig_it = headers.find("original-authorization"); + ASSERT_NE(orig_it, headers.end()); + EXPECT_EQ(orig_it->second, "Bearer my-oauth-token"); +} + +// Java: authenticateConflictingSigv4Headers +TEST_F(SigV4AuthTest, ConflictingSigV4HeadersRelocated) { + auto delegate = AuthSession::MakeDefault({ + {"x-amz-content-sha256", "fake-sha256"}, + {"X-Amz-Date", "fake-date"}, + {"Content-Type", "application/json"}, + }); + auto credentials = + std::make_shared(Aws::Auth::AWSCredentials( + "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")); + auto session = std::make_shared( + delegate, "us-east-1", "execute-api", credentials, + std::unordered_map{}); + + HTTPRequest request{.method = HttpMethod::kGet, .url = "http://localhost:8080/path"}; + auto auth_result = session->Authenticate(request); + ASSERT_THAT(auth_result, IsOk()); + const auto& headers = auth_result.value().headers; + + // The real x-amz-content-sha256 should be the empty body hash (signer overwrites fake) + EXPECT_EQ(headers.at("x-amz-content-sha256"), SigV4AuthSession::kEmptyBodySha256); + + // The fake values should be relocated since the signer produced different values + auto orig_sha_it = headers.find("Original-x-amz-content-sha256"); + ASSERT_NE(orig_sha_it, headers.end()); + EXPECT_EQ(orig_sha_it->second, "fake-sha256"); + + auto orig_date_it = headers.find("Original-X-Amz-Date"); + ASSERT_NE(orig_date_it, headers.end()); + EXPECT_EQ(orig_date_it->second, "fake-date"); + + // SigV4 Authorization present + EXPECT_NE(headers.find("authorization"), headers.end()); +} + +// Java: close (TestRESTSigV4AuthSession) +TEST_F(SigV4AuthTest, SessionCloseDelegatesToInner) { + auto delegate = AuthSession::MakeDefault({}); + auto credentials = std::make_shared( + Aws::Auth::AWSCredentials("id", "secret")); + auto session = std::make_shared( + delegate, "us-east-1", "execute-api", credentials, + std::unordered_map{}); + + // Close should succeed without error + EXPECT_THAT(session->Close(), IsOk()); +} + +// ---------- Tests ported from Java TestRESTSigV4AuthManager ---------- + +// Java: createCustomDelegate +TEST_F(SigV4AuthTest, CreateCustomDelegateNone) { + std::unordered_map properties = { + {AuthProperties::kAuthType, "sigv4"}, + {AuthProperties::kSigV4DelegateAuthType, "none"}, + {AuthProperties::kSigV4SigningRegion, "us-west-2"}, + {AuthProperties::kSigV4AccessKeyId, "id"}, + {AuthProperties::kSigV4SecretAccessKey, "secret"}, + }; + + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto session_result = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(session_result, IsOk()); + + // Authenticate should work with noop delegate + HTTPRequest request{.method = HttpMethod::kGet, .url = "https://example.com/v1/config"}; + auto auth_result = session_result.value()->Authenticate(request); + ASSERT_THAT(auth_result, IsOk()); + const auto& headers = auth_result.value().headers; + + EXPECT_NE(headers.find("authorization"), headers.end()); + EXPECT_EQ(headers.find("original-authorization"), headers.end()); +} + +// Java: createInvalidCustomDelegate +TEST_F(SigV4AuthTest, CreateInvalidCustomDelegateSigV4Circular) { + std::unordered_map properties = { + {AuthProperties::kAuthType, "sigv4"}, + {AuthProperties::kSigV4DelegateAuthType, "sigv4"}, + {AuthProperties::kSigV4SigningRegion, "us-east-1"}, + {AuthProperties::kSigV4AccessKeyId, "id"}, + {AuthProperties::kSigV4SecretAccessKey, "secret"}, + }; + + auto result = AuthManagers::Load("test-catalog", properties); + EXPECT_THAT(result, IsError(ErrorKind::kInvalidArgument)); + EXPECT_THAT(result, + HasErrorMessage("Cannot delegate a SigV4 auth manager to another SigV4")); +} + +// Java: contextualSession +TEST_F(SigV4AuthTest, ContextualSessionOverridesProperties) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kSigV4SigningRegion] = "us-west-2"; + + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto catalog_session = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(catalog_session, IsOk()); + + // Context overrides region and credentials + std::unordered_map context = { + {AuthProperties::kSigV4AccessKeyId, "id2"}, + {AuthProperties::kSigV4SecretAccessKey, "secret2"}, + {AuthProperties::kSigV4SigningRegion, "eu-west-1"}, + }; + + auto ctx_session = + manager_result.value()->ContextualSession(context, catalog_session.value()); + ASSERT_THAT(ctx_session, IsOk()); + + HTTPRequest request{.method = HttpMethod::kGet, .url = "https://example.com/v1/config"}; + auto auth_result = ctx_session.value()->Authenticate(request); + ASSERT_THAT(auth_result, IsOk()); + const auto& headers = auth_result.value().headers; + + auto auth_it = headers.find("authorization"); + ASSERT_NE(auth_it, headers.end()); + + EXPECT_TRUE(auth_it->second.find("eu-west-1") != std::string::npos) + << "Expected eu-west-1 in Authorization, got: " << auth_it->second; +} + +// Java: tableSession (with property override) +TEST_F(SigV4AuthTest, TableSessionOverridesProperties) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kSigV4SigningRegion] = "us-west-2"; + + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto catalog_session = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(catalog_session, IsOk()); + + // Table properties override region and credentials + std::unordered_map table_props = { + {AuthProperties::kSigV4AccessKeyId, "table-key-id"}, + {AuthProperties::kSigV4SecretAccessKey, "table-secret"}, + {AuthProperties::kSigV4SigningRegion, "ap-southeast-1"}, + }; + + iceberg::TableIdentifier table_id{.ns = iceberg::Namespace{{"db1"}}, .name = "table1"}; + auto table_session = manager_result.value()->TableSession(table_id, table_props, + catalog_session.value()); + ASSERT_THAT(table_session, IsOk()); + + HTTPRequest request{.method = HttpMethod::kGet, + .url = "https://example.com/v1/db1/tables/table1"}; + auto auth_result = table_session.value()->Authenticate(request); + ASSERT_THAT(auth_result, IsOk()); + const auto& headers = auth_result.value().headers; + + auto auth_it = headers.find("authorization"); + ASSERT_NE(auth_it, headers.end()); + + EXPECT_TRUE(auth_it->second.find("ap-southeast-1") != std::string::npos) + << "Expected ap-southeast-1 in Authorization, got: " << auth_it->second; +} + +TEST_F(SigV4AuthTest, TableSessionInheritsContextualOverrides) { + auto properties = MakeSigV4Properties(); + properties[AuthProperties::kSigV4SigningRegion] = "us-west-2"; + + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + auto catalog_session = manager_result.value()->CatalogSession(client_, properties); + ASSERT_THAT(catalog_session, IsOk()); + + auto ctx_session = manager_result.value()->ContextualSession( + {{AuthProperties::kSigV4SigningRegion, "eu-west-1"}}, catalog_session.value()); + ASSERT_THAT(ctx_session, IsOk()); + + iceberg::TableIdentifier table_id{.ns = iceberg::Namespace{{"db1"}}, .name = "table1"}; + auto table_session = manager_result.value()->TableSession(table_id, /*properties=*/{}, + ctx_session.value()); + ASSERT_THAT(table_session, IsOk()); + + HTTPRequest request{.method = HttpMethod::kGet, + .url = "https://example.com/v1/db1/tables/table1"}; + auto auth_result = table_session.value()->Authenticate(request); + ASSERT_THAT(auth_result, IsOk()); + + auto auth_it = auth_result.value().headers.find("authorization"); + ASSERT_NE(auth_it, auth_result.value().headers.end()); + EXPECT_TRUE(auth_it->second.find("eu-west-1") != std::string::npos) + << "Table session should inherit eu-west-1 from contextual parent, got: " + << auth_it->second; +} + +// Java: close (TestRESTSigV4AuthManager) +TEST_F(SigV4AuthTest, ManagerCloseDelegatesToInner) { + auto properties = MakeSigV4Properties(); + auto manager_result = AuthManagers::Load("test-catalog", properties); + ASSERT_THAT(manager_result, IsOk()); + + // Close should succeed without error + EXPECT_THAT(manager_result.value()->Close(), IsOk()); +} + +} // namespace iceberg::rest::auth