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/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..0ff3a5623 100644 --- a/src/iceberg/catalog/rest/auth/auth_managers.cc +++ b/src/iceberg/catalog/rest/auth/auth_managers.cc @@ -22,6 +22,9 @@ #include #include "iceberg/catalog/rest/auth/auth_manager_internal.h" +#ifdef ICEBERG_BUILD_SIGV4 +# include "iceberg/catalog/rest/auth/sigv4_auth_manager.h" +#endif #include "iceberg/catalog/rest/auth/auth_properties.h" #include "iceberg/util/string_util.h" @@ -62,11 +65,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. @@ -98,4 +105,28 @@ Result> AuthManagers::Load( return it->second(name, properties); } +#ifdef ICEBERG_BUILD_SIGV4 +Result> MakeSigV4AuthManager( + std::string_view name, + const std::unordered_map& properties) { + // Determine the delegate auth type. Default to OAuth2 if not specified. + std::string delegate_type = AuthProperties::kAuthTypeOAuth2; + auto it = properties.find(AuthProperties::kSigV4DelegateAuthType); + if (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"); + + // Load the delegate auth manager. + 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)); +} +#endif + } // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/auth/auth_properties.h b/src/iceberg/catalog/rest/auth/auth_properties.h index 05a7ea2c6..f5de44ea8 100644 --- a/src/iceberg/catalog/rest/auth/auth_properties.h +++ b/src/iceberg/catalog/rest/auth/auth_properties.h @@ -59,6 +59,20 @@ class ICEBERG_REST_EXPORT AuthProperties : public ConfigBase { inline static const std::string kSigV4DelegateAuthType = "rest.auth.sigv4.delegate-auth-type"; + // ---- SigV4 AWS credential entries ---- + + /// AWS region for SigV4 signing. + inline static const std::string kSigV4SigningRegion = "rest.signing-region"; + /// AWS service name for SigV4 signing. + inline static const std::string kSigV4SigningName = "rest.signing-name"; + inline static const std::string kSigV4SigningNameDefault = "execute-api"; + /// Static access key ID for SigV4 signing. + inline static const std::string kSigV4AccessKeyId = "rest.access-key-id"; + /// Static secret access key for SigV4 signing. + inline static const std::string kSigV4SecretAccessKey = "rest.secret-access-key"; + /// Optional session token for SigV4 signing. + inline static const std::string kSigV4SessionToken = "rest.session-token"; + // ---- OAuth2 entries ---- inline static Entry kToken{"token", ""}; diff --git a/src/iceberg/catalog/rest/auth/auth_session.cc b/src/iceberg/catalog/rest/auth/auth_session.cc index 7251dc4a9..a9956f5da 100644 --- a/src/iceberg/catalog/rest/auth/auth_session.cc +++ b/src/iceberg/catalog/rest/auth/auth_session.cc @@ -33,7 +33,9 @@ class DefaultAuthSession : public AuthSession { explicit DefaultAuthSession(std::unordered_map headers) : headers_(std::move(headers)) {} - Status Authenticate(std::unordered_map& headers) override { + Status Authenticate( + std::unordered_map& headers, + [[maybe_unused]] const HTTPRequestContext& request_context) override { for (const auto& [key, value] : headers_) { headers.try_emplace(key, value); } diff --git a/src/iceberg/catalog/rest/auth/auth_session.h b/src/iceberg/catalog/rest/auth/auth_session.h index 26b93877b..c6598ed42 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,6 +33,13 @@ namespace iceberg::rest::auth { +/// \brief Context about the HTTP request being authenticated. +struct ICEBERG_REST_EXPORT HTTPRequestContext { + HttpMethod method = HttpMethod::kGet; + std::string url; + std::string body; +}; + /// \brief An authentication session that can authenticate outgoing HTTP requests. class ICEBERG_REST_EXPORT AuthSession { public: @@ -50,7 +58,8 @@ class ICEBERG_REST_EXPORT AuthSession { /// - 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 Status Authenticate(std::unordered_map& headers, + const HTTPRequestContext& request_context) = 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..96603dbdb --- /dev/null +++ b/src/iceberg/catalog/rest/auth/sigv4_auth_manager.cc @@ -0,0 +1,309 @@ +/* + * 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_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; +} + +} // namespace + +// ---- SigV4AuthSession ---- + +SigV4AuthSession::SigV4AuthSession( + std::shared_ptr delegate, std::string signing_region, + std::string signing_name, + std::shared_ptr credentials_provider) + : 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(), + Aws::Client::AWSAuthV4Signer::PayloadSigningPolicy::Always, + /*urlEscapePath=*/false)) {} + +SigV4AuthSession::~SigV4AuthSession() = default; + +Status SigV4AuthSession::Authenticate( + std::unordered_map& headers, + const HTTPRequestContext& request_context) { + // 1. Delegate authenticates first (e.g., adds OAuth2 Bearer token). + ICEBERG_RETURN_UNEXPECTED(delegate_->Authenticate(headers, request_context)); + + auto original_headers = headers; + + // 2. Relocate Authorization header (case-insensitive) so SigV4 takes precedence. + std::unordered_map signing_headers; + for (const auto& [name, value] : headers) { + if (StringUtils::EqualsIgnoreCase(name, "Authorization")) { + signing_headers[std::string(kRelocatedHeaderPrefix) + name] = value; + } else { + signing_headers[name] = value; + } + } + + // 3. Build AWS SDK request. + Aws::Http::URI aws_uri(request_context.url.c_str()); + auto aws_request = std::make_shared( + aws_uri, ToAwsMethod(request_context.method)); + + for (const auto& [name, value] : signing_headers) { + aws_request->SetHeaderValue(Aws::String(name.c_str()), Aws::String(value.c_str())); + } + + // 4. Set body content hash (matching Java's RESTSigV4AuthSession). + // Empty body: set EMPTY_BODY_SHA256 explicitly (Java line 118-121 workaround). + // Non-empty body: set body stream; the signer (PayloadSigningPolicy::Always) + // computes the real hex hash. Step 7 converts hex to Base64 after signing. + if (request_context.body.empty()) { + aws_request->SetHeaderValue("x-amz-content-sha256", Aws::String(kEmptyBodySha256)); + } else { + auto body_stream = + Aws::MakeShared("SigV4Body", request_context.body); + aws_request->AddContentBody(body_stream); + } + + // 5. Sign. + if (!signer_->SignRequest(*aws_request)) { + return std::unexpected( + Error{ErrorKind::kAuthenticationFailed, "SigV4 signing failed"}); + } + + // 6. Extract signed headers, relocating conflicts with originals. + headers.clear(); + auto signed_headers = aws_request->GetHeaders(); + for (auto it = signed_headers.begin(); it != signed_headers.end(); ++it) { + std::string name_str(it->first.c_str(), it->first.size()); + std::string value_str(it->second.c_str(), it->second.size()); + + for (const auto& [orig_name, orig_value] : original_headers) { + if (StringUtils::EqualsIgnoreCase(orig_name, name_str) && orig_value != value_str) { + headers[std::string(kRelocatedHeaderPrefix) + orig_name] = orig_value; + break; + } + } + + headers[name_str] = value_str; + } + + // 7. Convert body hash from hex to Base64 (matching Java's SignerChecksumParams + // output). Only convert if the value is a valid hex SHA256 (64 hex chars). + if (!request_context.body.empty()) { + auto it = headers.find("x-amz-content-sha256"); + if (it != headers.end() && it->second.size() == 64 && + it->second != std::string(kEmptyBodySha256)) { + auto decoded = Aws::Utils::HashingUtils::HexDecode(Aws::String(it->second.c_str())); + it->second = std::string(Aws::Utils::HashingUtils::Base64Encode(decoded).c_str()); + } + } + + return {}; +} + +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(); + catalog_properties_ = properties; + 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 = dynamic_cast(parent.get()); + ICEBERG_PRECHECK(sigv4_parent != nullptr, "Parent session is not SigV4"); + + ICEBERG_ASSIGN_OR_RAISE(auto delegate_session, delegate_->ContextualSession( + context, sigv4_parent->delegate())); + + auto merged = MergeProperties(catalog_properties_, context); + return WrapSession(std::move(delegate_session), merged); +} + +Result> SigV4AuthManager::TableSession( + const TableIdentifier& table, + const std::unordered_map& properties, + std::shared_ptr parent) { + auto* sigv4_parent = dynamic_cast(parent.get()); + ICEBERG_PRECHECK(sigv4_parent != nullptr, "Parent session is not SigV4"); + + ICEBERG_ASSIGN_OR_RAISE( + auto delegate_session, + delegate_->TableSession(table, properties, sigv4_parent->delegate())); + + auto merged = MergeProperties(catalog_properties_, properties); + return WrapSession(std::move(delegate_session), merged); +} + +Status SigV4AuthManager::Close() { return delegate_->Close(); } + +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(); + + // Reject partial credentials — providing only one of AK/SK is a misconfiguration. + ICEBERG_PRECHECK( + has_ak == has_sk, "Both '{}' and '{}' must be set together, or neither", + AuthProperties::kSigV4AccessKeyId, AuthProperties::kSigV4SecretAccessKey); + + if (has_ak) { + auto session_token_it = properties.find(AuthProperties::kSigV4SessionToken); + if (session_token_it != properties.end() && !session_token_it->second.empty()) { + Aws::Auth::AWSCredentials credentials(access_key_it->second.c_str(), + secret_key_it->second.c_str(), + session_token_it->second.c_str()); + return std::make_shared(credentials); + } + Aws::Auth::AWSCredentials credentials(access_key_it->second.c_str(), + secret_key_it->second.c_str()); + return std::make_shared(credentials); + } + + return std::make_shared(); +} + +std::string SigV4AuthManager::ResolveSigningRegion( + const std::unordered_map& properties) { + auto it = properties.find(AuthProperties::kSigV4SigningRegion); + if (it != properties.end() && !it->second.empty()) { + return it->second; + } + auto legacy_it = properties.find(AuthProperties::kSigV4Region); + if (legacy_it != properties.end() && !legacy_it->second.empty()) { + return legacy_it->second; + } + if (const char* env = std::getenv("AWS_REGION")) { + return std::string(env); + } + if (const char* env = std::getenv("AWS_DEFAULT_REGION")) { + return std::string(env); + } + return "us-east-1"; +} + +std::string SigV4AuthManager::ResolveSigningName( + const std::unordered_map& properties) { + auto it = properties.find(AuthProperties::kSigV4SigningName); + if (it != properties.end() && !it->second.empty()) { + return it->second; + } + auto legacy_it = properties.find(AuthProperties::kSigV4Service); + if (legacy_it != properties.end() && !legacy_it->second.empty()) { + return legacy_it->second; + } + return AuthProperties::kSigV4SigningNameDefault; +} + +Result> SigV4AuthManager::WrapSession( + std::shared_ptr delegate_session, + const 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)); +} + +} // 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..7ee9aa7bc --- /dev/null +++ b/src/iceberg/catalog/rest/auth/sigv4_auth_manager.h @@ -0,0 +1,129 @@ +/* + * 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 NOT thread-safe. Each session should be used +/// from a single thread, or callers must synchronize externally. +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); + + ~SigV4AuthSession() override; + + Status Authenticate(std::unordered_map& headers, + const HTTPRequestContext& request_context) override; + + Status Close() override; + + const std::shared_ptr& delegate() const { return delegate_; } + + private: + std::shared_ptr delegate_; + std::string signing_region_; + std::string signing_name_; + std::shared_ptr credentials_provider_; + /// Shared signer instance, matching Java's single Aws4Signer per manager. + std::unique_ptr signer_; +}; + +/// \brief An AuthManager that produces SigV4AuthSession instances. +/// +/// Wraps a delegate AuthManager to handle double authentication (e.g., OAuth2 + SigV4). +/// +/// Thread safety: CatalogSession() must be called before ContextualSession() or +/// TableSession(). Concurrent calls are NOT safe — callers must synchronize externally. +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, + const std::unordered_map& properties); + + std::unique_ptr delegate_; + std::unordered_map catalog_properties_; +}; + +} // namespace iceberg::rest::auth diff --git a/src/iceberg/catalog/rest/http_client.cc b/src/iceberg/catalog/rest/http_client.cc index 2e383b0ae..f7d7c80b0 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 @@ -72,12 +74,12 @@ constexpr std::string_view kRestExceptionType = "RESTException"; Result BuildHeaders( const std::unordered_map& request_headers, const std::unordered_map& default_headers, - auth::AuthSession& session) { + auth::AuthSession& session, const auth::HTTPRequestContext& request_context) { std::unordered_map headers(default_headers); for (const auto& [key, val] : request_headers) { headers.insert_or_assign(key, val); } - ICEBERG_RETURN_UNEXPECTED(session.Authenticate(headers)); + ICEBERG_RETURN_UNEXPECTED(session.Authenticate(headers, request_context)); return cpr::Header(headers.begin(), headers.end()); } @@ -91,6 +93,24 @@ cpr::Parameters GetParameters( return cpr_params; } +/// \brief Append URL-encoded query parameters to a URL, sorted by key. +std::string AppendQueryString( + const std::string& base_url, + const std::unordered_map& params) { + 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 += "&"; + auto ek = EncodeString(k); + auto ev = EncodeString(v); + url += (ek ? *ek : k) + "=" + (ev ? *ev : v); + first = false; + } + return url; +} + /// \brief Checks if the HTTP status code indicates a successful response. bool IsSuccessful(int32_t status_code) { return status_code == 200 // OK @@ -149,8 +169,10 @@ 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)); + ICEBERG_ASSIGN_OR_RAISE( + auto all_headers, + BuildHeaders(headers, default_headers_, session, + {HttpMethod::kGet, AppendQueryString(path, params), ""})); cpr::Response response = cpr::Get(cpr::Url{path}, GetParameters(params), all_headers, *connection_pool_); @@ -164,8 +186,9 @@ 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 all_headers, + BuildHeaders(headers, default_headers_, session, {HttpMethod::kPost, path, body})); cpr::Response response = cpr::Post(cpr::Url{path}, cpr::Body{body}, all_headers, *connection_pool_); @@ -182,16 +205,20 @@ 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); } + // Use cpr's own encoding as the signing body to ensure consistency with the + // actual payload sent over the wire. + cpr::Payload payload(pair_list.begin(), pair_list.end()); + std::string encoded_body = payload.GetContent(); + ICEBERG_ASSIGN_OR_RAISE(auto all_headers, + BuildHeaders(form_headers, default_headers_, session, + {HttpMethod::kPost, path, 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{path}, std::move(payload), all_headers, *connection_pool_); ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); HttpResponse http_response; @@ -202,8 +229,9 @@ 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)); + ICEBERG_ASSIGN_OR_RAISE( + auto all_headers, + BuildHeaders(headers, default_headers_, session, {HttpMethod::kHead, path, ""})); cpr::Response response = cpr::Head(cpr::Url{path}, all_headers, *connection_pool_); ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); @@ -216,8 +244,10 @@ 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)); + ICEBERG_ASSIGN_OR_RAISE( + auto all_headers, + BuildHeaders(headers, default_headers_, session, + {HttpMethod::kDelete, AppendQueryString(path, params), ""})); cpr::Response response = cpr::Delete(cpr::Url{path}, GetParameters(params), all_headers, *connection_pool_); 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..6db8bc391 100644 --- a/src/iceberg/test/auth_manager_test.cc +++ b/src/iceberg/test/auth_manager_test.cc @@ -64,7 +64,7 @@ TEST_F(AuthManagerTest, LoadNoopAuthManagerExplicit) { ASSERT_THAT(session_result, IsOk()); std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); + EXPECT_THAT(session_result.value()->Authenticate(headers, {}), IsOk()); EXPECT_TRUE(headers.empty()); } @@ -108,7 +108,7 @@ TEST_F(AuthManagerTest, LoadBasicAuthManager) { ASSERT_THAT(session_result, IsOk()); std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); + EXPECT_THAT(session_result.value()->Authenticate(headers, {}), IsOk()); // base64("admin:secret") == "YWRtaW46c2VjcmV0" EXPECT_EQ(headers["Authorization"], "Basic YWRtaW46c2VjcmV0"); } @@ -127,7 +127,7 @@ TEST_F(AuthManagerTest, BasicAuthTypeCaseInsensitive) { ASSERT_THAT(session_result, IsOk()) << "Failed for auth type: " << auth_type; std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); + EXPECT_THAT(session_result.value()->Authenticate(headers, {}), IsOk()); // base64("user:pass") == "dXNlcjpwYXNz" EXPECT_EQ(headers["Authorization"], "Basic dXNlcjpwYXNz"); } @@ -173,7 +173,7 @@ TEST_F(AuthManagerTest, BasicAuthSpecialCharacters) { ASSERT_THAT(session_result, IsOk()); std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); + EXPECT_THAT(session_result.value()->Authenticate(headers, {}), IsOk()); // base64("user@domain.com:p@ss:w0rd!") == "dXNlckBkb21haW4uY29tOnBAc3M6dzByZCE=" EXPECT_EQ(headers["Authorization"], "Basic dXNlckBkb21haW4uY29tOnBAc3M6dzByZCE="); } @@ -205,7 +205,7 @@ TEST_F(AuthManagerTest, RegisterCustomAuthManager) { ASSERT_THAT(session_result, IsOk()); std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); + EXPECT_THAT(session_result.value()->Authenticate(headers, {}), IsOk()); EXPECT_EQ(headers["X-Custom-Auth"], "custom-value"); } @@ -223,7 +223,7 @@ TEST_F(AuthManagerTest, OAuth2StaticToken) { ASSERT_THAT(session_result, IsOk()); std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); + EXPECT_THAT(session_result.value()->Authenticate(headers, {}), IsOk()); EXPECT_EQ(headers["Authorization"], "Bearer my-static-token"); } @@ -240,7 +240,7 @@ TEST_F(AuthManagerTest, OAuth2InferredFromToken) { ASSERT_THAT(session_result, IsOk()); std::unordered_map headers; - EXPECT_THAT(session_result.value()->Authenticate(headers), IsOk()); + EXPECT_THAT(session_result.value()->Authenticate(headers, {}), IsOk()); EXPECT_EQ(headers["Authorization"], "Bearer inferred-token"); } @@ -259,7 +259,7 @@ TEST_F(AuthManagerTest, OAuth2MissingCredentials) { // Session should have no auth headers std::unordered_map headers; - ASSERT_TRUE(session_result.value()->Authenticate(headers).has_value()); + ASSERT_TRUE(session_result.value()->Authenticate(headers, {}).has_value()); EXPECT_EQ(headers.find("Authorization"), headers.end()); } @@ -280,7 +280,7 @@ TEST_F(AuthManagerTest, OAuth2TokenTakesPriorityOverCredential) { ASSERT_THAT(session_result, IsOk()); std::unordered_map headers; - ASSERT_THAT(session_result.value()->Authenticate(headers), IsOk()); + ASSERT_THAT(session_result.value()->Authenticate(headers, {}), IsOk()); EXPECT_EQ(headers["Authorization"], "Bearer my-static-token"); } diff --git a/src/iceberg/test/sigv4_auth_test.cc b/src/iceberg/test/sigv4_auth_test.cc new file mode 100644 index 000000000..159339d03 --- /dev/null +++ b/src/iceberg/test/sigv4_auth_test.cc @@ -0,0 +1,487 @@ +/* + * 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()); + + std::unordered_map headers; + HTTPRequestContext ctx{HttpMethod::kGet, "https://example.com/v1/config", ""}; + ASSERT_THAT(session_result.value()->Authenticate(headers, ctx), IsOk()); + + EXPECT_NE(headers.find("authorization"), headers.end()); + EXPECT_TRUE(headers["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()); + + std::unordered_map headers; + headers["Content-Type"] = "application/json"; + HTTPRequestContext ctx{HttpMethod::kPost, "https://example.com/v1/namespaces", + R"({"namespace":["ns1"]})"}; + ASSERT_THAT(session_result.value()->Authenticate(headers, ctx), IsOk()); + + EXPECT_NE(headers.find("authorization"), headers.end()); + EXPECT_TRUE(headers["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()); + + std::unordered_map headers; + HTTPRequestContext ctx{HttpMethod::kGet, "https://example.com/v1/config", ""}; + ASSERT_THAT(session_result.value()->Authenticate(headers, ctx), IsOk()); + + EXPECT_NE(headers.find("authorization"), headers.end()); + EXPECT_TRUE(headers["authorization"].starts_with("AWS4-HMAC-SHA256")); + EXPECT_NE(headers.find("original-authorization"), headers.end()); + EXPECT_EQ(headers["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()); + + std::unordered_map headers; + HTTPRequestContext ctx{HttpMethod::kGet, "https://example.com/v1/config", ""}; + ASSERT_THAT(session_result.value()->Authenticate(headers, ctx), IsOk()); + + EXPECT_NE(headers.find("authorization"), headers.end()); + EXPECT_NE(headers.find("x-amz-security-token"), headers.end()); + EXPECT_EQ(headers["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()); + + std::unordered_map headers; + HTTPRequestContext ctx{HttpMethod::kGet, "https://example.com/v1/config", ""}; + ASSERT_THAT(session_result.value()->Authenticate(headers, ctx), IsOk()); + + 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()); + + std::unordered_map headers; + HTTPRequestContext ctx{HttpMethod::kGet, "https://example.com/v1/config", ""}; + ASSERT_THAT(session_result.value()->Authenticate(headers, ctx), IsOk()); + + 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{iceberg::Namespace{{"ns1"}}, "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()); + + std::unordered_map headers; + HTTPRequestContext ctx{HttpMethod::kGet, "https://example.com/v1/ns1/tables/table1", + ""}; + ASSERT_THAT(table_session.value()->Authenticate(headers, ctx), IsOk()); + EXPECT_NE(headers.find("authorization"), 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()); + + std::unordered_map headers; + headers["Content-Type"] = "application/json"; + HTTPRequestContext ctx{HttpMethod::kGet, "http://localhost:8080/path", ""}; + ASSERT_THAT(session_result.value()->Authenticate(headers, ctx), IsOk()); + + // Original header preserved + EXPECT_EQ(headers["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["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()); + + std::unordered_map headers; + headers["Content-Type"] = "application/json"; + std::string body = R"({"namespace":["ns1"]})"; + HTTPRequestContext ctx{HttpMethod::kPost, "http://localhost:8080/path", body}; + ASSERT_THAT(session_result.value()->Authenticate(headers, ctx), IsOk()); + + // 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()); + + std::unordered_map headers; + headers["Content-Type"] = "application/json"; + HTTPRequestContext ctx{HttpMethod::kGet, "http://localhost:8080/path", ""}; + ASSERT_THAT(session_result.value()->Authenticate(headers, ctx), IsOk()); + + // 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 headers; + HTTPRequestContext ctx{HttpMethod::kGet, "http://localhost:8080/path", ""}; + ASSERT_THAT(session->Authenticate(headers, ctx), IsOk()); + + // The real x-amz-content-sha256 should be the empty body hash (signer overwrites fake) + EXPECT_EQ(headers["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); + + // 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 + std::unordered_map headers; + HTTPRequestContext ctx{HttpMethod::kGet, "https://example.com/v1/config", ""}; + ASSERT_THAT(session_result.value()->Authenticate(headers, ctx), IsOk()); + + EXPECT_NE(headers.find("authorization"), headers.end()); + + EXPECT_EQ(headers.find("original-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()); + + std::unordered_map headers; + HTTPRequestContext req_ctx{HttpMethod::kGet, "https://example.com/v1/config", ""}; + ASSERT_THAT(ctx_session.value()->Authenticate(headers, req_ctx), IsOk()); + + 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{iceberg::Namespace{{"db1"}}, "table1"}; + auto table_session = manager_result.value()->TableSession(table_id, table_props, + catalog_session.value()); + ASSERT_THAT(table_session, IsOk()); + + std::unordered_map headers; + HTTPRequestContext req_ctx{HttpMethod::kGet, "https://example.com/v1/db1/tables/table1", + ""}; + ASSERT_THAT(table_session.value()->Authenticate(headers, req_ctx), IsOk()); + + 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; +} + +// 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