| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| #include "iceberg/catalog/rest/auth/auth_manager.h" |
| |
| #include <atomic> |
| #include <chrono> |
| #include <condition_variable> |
| #include <mutex> |
| #include <string> |
| #include <thread> |
| #include <unordered_map> |
| #include <vector> |
| |
| #include <gmock/gmock.h> |
| #include <gtest/gtest.h> |
| #include <nlohmann/json.hpp> |
| |
| #include "iceberg/catalog/rest/auth/auth_managers.h" |
| #include "iceberg/catalog/rest/auth/auth_properties.h" |
| #include "iceberg/catalog/rest/auth/auth_session.h" |
| #include "iceberg/catalog/rest/auth/oauth2_util.h" |
| #include "iceberg/catalog/rest/auth/token_refresh_scheduler.h" |
| #include "iceberg/catalog/rest/error_handlers.h" |
| #include "iceberg/catalog/rest/http_client.h" |
| #include "iceberg/catalog/rest/json_serde_internal.h" |
| #include "iceberg/catalog/session_context.h" |
| #include "iceberg/json_serde_internal.h" |
| #include "iceberg/test/matchers.h" |
| #include "iceberg/util/base64.h" |
| |
| namespace iceberg::rest::auth { |
| |
| namespace { |
| |
| /// Helper to parse OAuthTokenResponse from a JSON string. |
| Result<OAuthTokenResponse> ParseTokenResponse(const std::string& str) { |
| ICEBERG_ASSIGN_OR_RAISE(auto json, iceberg::FromJsonString(str)); |
| return iceberg::rest::FromJson<OAuthTokenResponse>(json); |
| } |
| |
| std::string MakeJwt(const std::string& payload_json) { |
| std::string header = R"({"alg":"HS256","typ":"JWT"})"; |
| std::string signature = "test-signature"; |
| return Base64::UrlEncode(header) + "." + Base64::UrlEncode(payload_json) + "." + |
| Base64::UrlEncode(signature); |
| } |
| |
| } // namespace |
| |
| class AuthManagerTest : public ::testing::Test { |
| protected: |
| HttpClient client_{{}}; |
| }; |
| |
| // Verifies loading NoopAuthManager with explicit "none" auth type |
| TEST_F(AuthManagerTest, LoadNoopAuthManagerExplicit) { |
| std::unordered_map<std::string, std::string> properties = { |
| {AuthProperties::kAuthType, "none"}}; |
| |
| 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()); |
| |
| 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 |
| TEST_F(AuthManagerTest, LoadNoopAuthManagerInferred) { |
| auto manager_result = AuthManagers::Load("test-catalog", {}); |
| ASSERT_THAT(manager_result, IsOk()); |
| } |
| |
| TEST_F(AuthManagerTest, NoopContextualSessionReturnsParentSession) { |
| ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog", {})); |
| ICEBERG_UNWRAP_OR_FAIL(auto parent, manager->CatalogSession(client_, {})); |
| |
| SessionContext context{ |
| .session_id = "tenant-a", |
| .identity = "user-a", |
| .credentials = {{"credential-key", "credential-value"}}, |
| .properties = {{"property-key", "property-value"}}, |
| }; |
| ICEBERG_UNWRAP_OR_FAIL(auto contextual, manager->ContextualSession(context, parent)); |
| |
| EXPECT_EQ(contextual, parent); |
| } |
| |
| TEST_F(AuthManagerTest, HttpHeadersAreCaseInsensitiveSingleValueMap) { |
| HttpHeaders headers; |
| headers.emplace("Authorization", "Bearer first"); |
| headers.emplace("authorization", "Bearer second"); |
| |
| EXPECT_EQ(headers.size(), 1); |
| EXPECT_EQ(headers.at("AUTHORIZATION"), "Bearer first"); |
| } |
| |
| TEST_F(AuthManagerTest, HttpClientRejectsParamsWhenUrlAlreadyHasQuery) { |
| auto session = AuthSession::MakeDefault({}); |
| auto result = |
| client_.Get("http://127.0.0.1/v1/config?existing=true", {{"warehouse", "prod"}}, |
| /*headers=*/{}, *rest::DefaultErrorHandler::Instance(), *session); |
| |
| EXPECT_THAT(result, IsError(ErrorKind::kInvalidArgument)); |
| EXPECT_THAT(result, HasErrorMessage("must not contain a query string")); |
| } |
| |
| // Verifies that auth type is case-insensitive |
| TEST_F(AuthManagerTest, AuthTypeCaseInsensitive) { |
| for (const auto& auth_type : {"NONE", "None", "NoNe"}) { |
| std::unordered_map<std::string, std::string> properties = { |
| {AuthProperties::kAuthType, auth_type}}; |
| EXPECT_THAT(AuthManagers::Load("test-catalog", properties), IsOk()) |
| << "Failed for auth type: " << auth_type; |
| } |
| } |
| |
| // Verifies that unknown auth type returns InvalidArgument |
| TEST_F(AuthManagerTest, UnknownAuthTypeReturnsInvalidArgument) { |
| std::unordered_map<std::string, std::string> properties = { |
| {AuthProperties::kAuthType, "unknown-auth-type"}}; |
| |
| auto result = AuthManagers::Load("test-catalog", properties); |
| EXPECT_THAT(result, IsError(ErrorKind::kInvalidArgument)); |
| EXPECT_THAT(result, HasErrorMessage("Unknown authentication type")); |
| } |
| |
| // Verifies loading BasicAuthManager with valid credentials |
| TEST_F(AuthManagerTest, LoadBasicAuthManager) { |
| std::unordered_map<std::string, std::string> properties = { |
| {AuthProperties::kAuthType, "basic"}, |
| {AuthProperties::kBasicUsername, "admin"}, |
| {AuthProperties::kBasicPassword, "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()); |
| |
| auto auth_result = session_result.value()->Authenticate({}); |
| ASSERT_THAT(auth_result, IsOk()); |
| // base64("admin:secret") == "YWRtaW46c2VjcmV0" |
| EXPECT_EQ(auth_result.value().headers["Authorization"], "Basic YWRtaW46c2VjcmV0"); |
| } |
| |
| // Verifies BasicAuthManager is case-insensitive for auth type |
| TEST_F(AuthManagerTest, BasicAuthTypeCaseInsensitive) { |
| for (const auto& auth_type : {"BASIC", "Basic", "bAsIc"}) { |
| std::unordered_map<std::string, std::string> properties = { |
| {AuthProperties::kAuthType, auth_type}, |
| {AuthProperties::kBasicUsername, "user"}, |
| {AuthProperties::kBasicPassword, "pass"}}; |
| auto manager_result = AuthManagers::Load("test-catalog", properties); |
| ASSERT_THAT(manager_result, IsOk()) << "Failed for auth type: " << auth_type; |
| |
| auto session_result = manager_result.value()->CatalogSession(client_, properties); |
| ASSERT_THAT(session_result, IsOk()) << "Failed for auth type: " << auth_type; |
| |
| auto auth_result = session_result.value()->Authenticate({}); |
| ASSERT_THAT(auth_result, IsOk()) << "Failed for auth type: " << auth_type; |
| // base64("user:pass") == "dXNlcjpwYXNz" |
| EXPECT_EQ(auth_result.value().headers["Authorization"], "Basic dXNlcjpwYXNz"); |
| } |
| } |
| |
| // Verifies BasicAuthManager fails when username is missing |
| TEST_F(AuthManagerTest, BasicAuthMissingUsername) { |
| std::unordered_map<std::string, std::string> properties = { |
| {AuthProperties::kAuthType, "basic"}, {AuthProperties::kBasicPassword, "secret"}}; |
| |
| auto manager_result = AuthManagers::Load("test-catalog", properties); |
| ASSERT_THAT(manager_result, IsOk()); |
| |
| auto session_result = manager_result.value()->CatalogSession(client_, properties); |
| EXPECT_THAT(session_result, IsError(ErrorKind::kInvalidArgument)); |
| EXPECT_THAT(session_result, HasErrorMessage("Missing required property")); |
| } |
| |
| // Verifies BasicAuthManager fails when password is missing |
| TEST_F(AuthManagerTest, BasicAuthMissingPassword) { |
| std::unordered_map<std::string, std::string> properties = { |
| {AuthProperties::kAuthType, "basic"}, {AuthProperties::kBasicUsername, "admin"}}; |
| |
| auto manager_result = AuthManagers::Load("test-catalog", properties); |
| ASSERT_THAT(manager_result, IsOk()); |
| |
| auto session_result = manager_result.value()->CatalogSession(client_, properties); |
| EXPECT_THAT(session_result, IsError(ErrorKind::kInvalidArgument)); |
| EXPECT_THAT(session_result, HasErrorMessage("Missing required property")); |
| } |
| |
| // Verifies BasicAuthManager handles special characters in credentials |
| TEST_F(AuthManagerTest, BasicAuthSpecialCharacters) { |
| std::unordered_map<std::string, std::string> properties = { |
| {AuthProperties::kAuthType, "basic"}, |
| {AuthProperties::kBasicUsername, "user@domain.com"}, |
| {AuthProperties::kBasicPassword, "p@ss:w0rd!"}}; |
| |
| 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()); |
| |
| auto auth_result = session_result.value()->Authenticate({}); |
| ASSERT_THAT(auth_result, IsOk()); |
| // base64("user@domain.com:p@ss:w0rd!") == "dXNlckBkb21haW4uY29tOnBAc3M6dzByZCE=" |
| EXPECT_EQ(auth_result.value().headers["Authorization"], |
| "Basic dXNlckBkb21haW4uY29tOnBAc3M6dzByZCE="); |
| } |
| |
| // Verifies custom auth manager registration |
| TEST_F(AuthManagerTest, RegisterCustomAuthManager) { |
| AuthManagers::Register( |
| "custom", |
| []([[maybe_unused]] std::string_view name, |
| [[maybe_unused]] const std::unordered_map<std::string, std::string>& props) |
| -> Result<std::unique_ptr<AuthManager>> { |
| class CustomAuthManager : public AuthManager { |
| public: |
| Result<std::shared_ptr<AuthSession>> CatalogSession( |
| HttpClient&, const std::unordered_map<std::string, std::string>&) override { |
| return AuthSession::MakeDefault({{"X-Custom-Auth", "custom-value"}}); |
| } |
| }; |
| return std::make_unique<CustomAuthManager>(); |
| }); |
| |
| std::unordered_map<std::string, std::string> properties = { |
| {AuthProperties::kAuthType, "custom"}}; |
| |
| 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()); |
| |
| 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 |
| TEST_F(AuthManagerTest, OAuth2StaticToken) { |
| std::unordered_map<std::string, std::string> properties = { |
| {AuthProperties::kAuthType, "oauth2"}, |
| {AuthProperties::kToken.key(), "my-static-token"}, |
| }; |
| |
| auto manager_result = AuthManagers::Load("test-catalog", properties); |
| ASSERT_THAT(manager_result, IsOk()); |
| |
| auto session_result = manager_result.value()->CatalogSession(client_, properties); |
| ASSERT_THAT(session_result, IsOk()); |
| |
| 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 |
| TEST_F(AuthManagerTest, OAuth2InferredFromToken) { |
| std::unordered_map<std::string, std::string> properties = { |
| {AuthProperties::kToken.key(), "inferred-token"}, |
| }; |
| |
| auto manager_result = AuthManagers::Load("test-catalog", properties); |
| ASSERT_THAT(manager_result, IsOk()); |
| |
| auto session_result = manager_result.value()->CatalogSession(client_, properties); |
| ASSERT_THAT(session_result, IsOk()); |
| |
| 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 |
| // provided |
| TEST_F(AuthManagerTest, OAuth2MissingCredentials) { |
| std::unordered_map<std::string, std::string> properties = { |
| {AuthProperties::kAuthType, "oauth2"}, |
| }; |
| |
| auto manager_result = AuthManagers::Load("test-catalog", properties); |
| ASSERT_THAT(manager_result, IsOk()); |
| |
| auto session_result = manager_result.value()->CatalogSession(client_, properties); |
| ASSERT_THAT(session_result, IsOk()); |
| |
| // Session should have no auth headers |
| 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 |
| // in CatalogSession (without a prior InitSession call) |
| TEST_F(AuthManagerTest, OAuth2TokenTakesPriorityOverCredential) { |
| std::unordered_map<std::string, std::string> properties = { |
| {AuthProperties::kAuthType, "oauth2"}, |
| {AuthProperties::kCredential.key(), "secret-only"}, |
| {AuthProperties::kToken.key(), "my-static-token"}, |
| {"uri", "http://localhost:8181"}, |
| }; |
| |
| auto manager_result = AuthManagers::Load("test-catalog", properties); |
| ASSERT_THAT(manager_result, IsOk()); |
| |
| auto session_result = manager_result.value()->CatalogSession(client_, properties); |
| ASSERT_THAT(session_result, IsOk()); |
| |
| 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 |
| TEST_F(AuthManagerTest, OAuthTokenResponseParsing) { |
| std::string json = R"({ |
| "access_token": "test-access-token", |
| "token_type": "bearer", |
| "expires_in": 3600, |
| "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", |
| "refresh_token": "test-refresh-token", |
| "scope": "catalog" |
| })"; |
| |
| auto result = ParseTokenResponse(json); |
| ASSERT_THAT(result, IsOk()); |
| EXPECT_EQ(result->access_token, "test-access-token"); |
| EXPECT_EQ(result->token_type, "bearer"); |
| ASSERT_TRUE(result->expires_in_secs.has_value()); |
| EXPECT_EQ(result->expires_in_secs.value(), 3600); |
| EXPECT_EQ(result->issued_token_type, "urn:ietf:params:oauth:token-type:access_token"); |
| EXPECT_EQ(result->refresh_token, "test-refresh-token"); |
| EXPECT_EQ(result->scope, "catalog"); |
| } |
| |
| // Verifies OAuthTokenResponse parsing with minimal fields |
| TEST_F(AuthManagerTest, OAuthTokenResponseMinimal) { |
| std::string json = R"({ |
| "access_token": "token123", |
| "token_type": "Bearer" |
| })"; |
| |
| auto result = ParseTokenResponse(json); |
| ASSERT_THAT(result, IsOk()); |
| EXPECT_EQ(result->access_token, "token123"); |
| EXPECT_EQ(result->token_type, "Bearer"); |
| EXPECT_FALSE(result->expires_in_secs.has_value()); |
| EXPECT_TRUE(result->issued_token_type.empty()); |
| EXPECT_TRUE(result->refresh_token.empty()); |
| EXPECT_TRUE(result->scope.empty()); |
| } |
| |
| // Verifies OAuthTokenResponse validation fails when access_token is missing |
| TEST_F(AuthManagerTest, OAuthTokenResponseMissingAccessToken) { |
| std::string json = R"({"token_type": "bearer"})"; |
| auto result = ParseTokenResponse(json); |
| EXPECT_THAT(result, ::testing::Not(IsOk())); |
| } |
| |
| // Verifies OAuthTokenResponse validation fails when token_type is missing |
| TEST_F(AuthManagerTest, OAuthTokenResponseMissingTokenType) { |
| std::string json = R"({"access_token": "token123"})"; |
| auto result = ParseTokenResponse(json); |
| EXPECT_THAT(result, ::testing::Not(IsOk())); |
| } |
| |
| // Verifies OAuthTokenResponse validation fails for unsupported token_type |
| TEST_F(AuthManagerTest, OAuthTokenResponseUnsupportedTokenType) { |
| std::string json = R"({ |
| "access_token": "token123", |
| "token_type": "mac" |
| })"; |
| auto result = ParseTokenResponse(json); |
| EXPECT_THAT(result, ::testing::Not(IsOk())); |
| } |
| |
| // Verifies OAuthTokenResponse accepts N_A token type |
| TEST_F(AuthManagerTest, OAuthTokenResponseNATokenType) { |
| std::string json = R"({ |
| "access_token": "token123", |
| "token_type": "N_A" |
| })"; |
| auto result = ParseTokenResponse(json); |
| ASSERT_THAT(result, IsOk()); |
| EXPECT_EQ(result->token_type, "N_A"); |
| } |
| |
| // ---- ExpiresAtMillis tests ---- |
| |
| TEST_F(AuthManagerTest, ExpiresAtMillisValidJwt) { |
| std::string token = MakeJwt(R"({"sub":"user","exp":1700000000})"); |
| |
| auto result = ExpiresAtMillis(token); |
| |
| ASSERT_TRUE(result.has_value()); |
| EXPECT_EQ(result.value(), 1700000000LL * 1000); |
| } |
| |
| TEST_F(AuthManagerTest, ExpiresAtMillisInvalidTokensReturnNullopt) { |
| std::vector<std::string> tokens = { |
| "", |
| "just-a-plain-token", |
| "part1.part2", |
| "a.b.c.d", |
| MakeJwt(R"({"sub":"user","iat":1700000000})"), |
| MakeJwt(R"({"exp":"not-a-number"})"), |
| "eyJhbGciOiJIUzI1NiJ9.!!!invalid!!!.signature", |
| Base64::UrlEncode(R"({"alg":"HS256"})") + "." + |
| Base64::UrlEncode("this is not json") + ".sig", |
| }; |
| |
| for (const auto& token : tokens) { |
| EXPECT_FALSE(ExpiresAtMillis(token).has_value()) << token; |
| } |
| } |
| |
| // ---- TokenRefreshScheduler tests ---- |
| |
| // Verifies that a scheduled task fires after the specified delay |
| TEST(TokenRefreshSchedulerTest, ScheduleFiresAfterDelay) { |
| TokenRefreshScheduler scheduler; |
| std::mutex mutex; |
| std::condition_variable cv; |
| bool fired = false; |
| |
| scheduler.Schedule(std::chrono::milliseconds(50), [&] { |
| { |
| std::lock_guard lock(mutex); |
| fired = true; |
| } |
| cv.notify_one(); |
| }); |
| |
| { |
| std::unique_lock lock(mutex); |
| EXPECT_TRUE(cv.wait_for(lock, std::chrono::seconds(5), [&] { return fired; })); |
| } |
| |
| scheduler.Shutdown(); |
| } |
| |
| // Verifies that cancelling a task prevents it from executing |
| TEST(TokenRefreshSchedulerTest, CancelPreventsExecution) { |
| TokenRefreshScheduler scheduler; |
| std::atomic<bool> fired{false}; |
| |
| auto handle = |
| scheduler.Schedule(std::chrono::milliseconds(100), [&] { fired.store(true); }); |
| |
| // Cancel before it fires |
| scheduler.Cancel(handle); |
| |
| // Wait past the scheduled time |
| std::this_thread::sleep_for(std::chrono::milliseconds(200)); |
| EXPECT_FALSE(fired.load()); |
| |
| scheduler.Shutdown(); |
| } |
| |
| // Verifies that shutdown with pending tasks does not crash |
| TEST(TokenRefreshSchedulerTest, ShutdownWithPendingTasks) { |
| TokenRefreshScheduler scheduler; |
| std::atomic<bool> fired{false}; |
| |
| scheduler.Schedule(std::chrono::milliseconds(5000), [&] { fired.store(true); }); |
| |
| // Shutdown immediately — should not crash and task should not fire |
| scheduler.Shutdown(); |
| |
| std::this_thread::sleep_for(std::chrono::milliseconds(50)); |
| EXPECT_FALSE(fired.load()); |
| } |
| |
| // Verifies that Schedule after shutdown returns invalid handle (0) |
| TEST(TokenRefreshSchedulerTest, ScheduleAfterShutdownIsNoop) { |
| TokenRefreshScheduler scheduler; |
| scheduler.Shutdown(); |
| |
| auto handle = scheduler.Schedule(std::chrono::milliseconds(10), [] {}); |
| EXPECT_EQ(0u, handle); |
| } |
| |
| // Verifies that cancelling an invalid handle does not crash |
| TEST(TokenRefreshSchedulerTest, CancelInvalidHandleIsNoop) { |
| TokenRefreshScheduler scheduler; |
| // Should not crash |
| scheduler.Cancel(0); |
| scheduler.Cancel(999); |
| scheduler.Shutdown(); |
| } |
| |
| // ---- OAuth2AuthSession tests ---- |
| |
| TEST(OAuth2AuthSessionTest, InitialTokenIsUsed) { |
| HttpClient client({}); |
| OAuthTokenResponse token_response; |
| token_response.access_token = "initial-token-123"; |
| token_response.token_type = "bearer"; |
| token_response.expires_in_secs = 3600; |
| |
| // Create session (refresh will fail since there's no real server, but |
| // initial token should work) |
| auto session_result = |
| AuthSession::MakeOAuth2(token_response, "http://localhost/oauth/tokens", |
| "client_id", "client_secret", "catalog", true, {}, client); |
| ASSERT_THAT(session_result, IsOk()); |
| auto session = session_result.value(); |
| |
| auto auth_result = session->Authenticate({}); |
| ASSERT_THAT(auth_result, IsOk()); |
| EXPECT_EQ(auth_result.value().headers.at("Authorization"), "Bearer initial-token-123"); |
| |
| session->Close(); |
| } |
| |
| } // namespace iceberg::rest::auth |