blob: f6f1400b2bbad52808aa090986692db0c4b9b58f [file]
/*
* 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 <array>
#include <atomic>
#include <chrono>
#include <functional>
#include <future>
#include <memory>
#include <stdexcept>
#include <string>
#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/auth_session_internal.h"
#include "iceberg/catalog/rest/auth/oauth2_util.h"
#include "iceberg/catalog/rest/catalog_properties.h"
#include "iceberg/catalog/rest/error_handlers.h"
#include "iceberg/catalog/rest/http_client.h"
#include "iceberg/catalog/rest/json_serde_internal.h"
#include "iceberg/catalog/session_context.h"
#include "iceberg/json_serde_internal.h"
#include "iceberg/table_identifier.h"
#include "iceberg/test/executor.h"
#include "iceberg/test/matchers.h"
#include "iceberg/test/retry.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:
std::shared_ptr<HttpClient> client_ = std::make_shared<HttpClient>();
};
TEST(AuthPropertiesTest, ResolvesDefaultOAuth2ServerUri) {
ICEBERG_UNWRAP_OR_FAIL(
auto config,
AuthProperties::FromProperties({
{RestCatalogProperties::kUri.key(), "https://catalog.example.com/api/"},
{RestCatalogProperties::kPrefix.key(), "warehouse"},
}));
EXPECT_EQ(config.oauth2_server_uri(),
"https://catalog.example.com/api/v1/oauth/tokens");
}
TEST(AuthPropertiesTest, PreservesEmptyOAuth2ServerUri) {
ICEBERG_UNWRAP_OR_FAIL(
auto config, AuthProperties::FromProperties({
{RestCatalogProperties::kUri.key(), "https://catalog.example.com"},
{AuthProperties::kOAuth2ServerUri.key(), ""},
}));
EXPECT_TRUE(config.oauth2_server_uri().empty());
}
TEST(AuthPropertiesTest, ResolvesExplicitRelativeOAuth2ServerUri) {
ICEBERG_UNWRAP_OR_FAIL(
auto config,
AuthProperties::FromProperties({
{RestCatalogProperties::kUri.key(), "https://catalog.example.com/api/"},
{AuthProperties::kOAuth2ServerUri.key(), "oauth/token/"},
}));
EXPECT_EQ(config.oauth2_server_uri(), "https://catalog.example.com/api/oauth/token/");
}
TEST(AuthPropertiesTest, PreservesExplicitAbsoluteOAuth2ServerUri) {
ICEBERG_UNWRAP_OR_FAIL(
auto config,
AuthProperties::FromProperties({
{RestCatalogProperties::kUri.key(), "https://catalog.example.com"},
{AuthProperties::kOAuth2ServerUri.key(), "https://auth.example.com/token/"},
}));
EXPECT_EQ(config.oauth2_server_uri(), "https://auth.example.com/token/");
}
TEST(AuthPropertiesTest, PreservesRelativeOAuth2ServerUriWithoutCatalogUri) {
ICEBERG_UNWRAP_OR_FAIL(auto config,
AuthProperties::FromProperties({
{AuthProperties::kOAuth2ServerUri.key(), "oauth/token"},
}));
EXPECT_EQ(config.oauth2_server_uri(), "oauth/token");
}
TEST(AuthPropertiesTest, PreservesAbsolutePathWithoutCatalogUri) {
ICEBERG_UNWRAP_OR_FAIL(auto config,
AuthProperties::FromProperties({
{AuthProperties::kOAuth2ServerUri.key(), "/oauth/token"},
}));
EXPECT_EQ(config.oauth2_server_uri(), "/oauth/token");
}
TEST(AuthPropertiesTest, ResolvesOAuth2ServerUriWithLeadingSlash) {
ICEBERG_UNWRAP_OR_FAIL(
auto config,
AuthProperties::FromProperties({
{RestCatalogProperties::kUri.key(), "https://catalog.example.com/api/"},
{AuthProperties::kOAuth2ServerUri.key(), "/v1/oauth/tokens"},
}));
EXPECT_EQ(config.oauth2_server_uri(),
"https://catalog.example.com/api/v1/oauth/tokens");
}
// 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, DefaultSessionPreservesRequestAuthorizationHeader) {
auto session = AuthSession::MakeDefault(OAuth2Util::AuthHeaders("parent-token"));
ICEBERG_UNWRAP_OR_FAIL(
auto authenticated,
session->Authenticate({.headers = {{"Authorization", "Basic credentials"}}}));
EXPECT_EQ(authenticated.headers.at("Authorization"), "Basic credentials");
EXPECT_FALSE(session->OAuth2Info().has_value());
}
TEST_F(AuthManagerTest, OAuth2SessionPreservesRequestAuthorizationHeader) {
OAuthTokenResponse token_response{
.access_token = "parent-token",
.token_type = "bearer",
};
ICEBERG_UNWRAP_OR_FAIL(
auto session,
AuthSession::MakeOAuth2(token_response, "https://auth.example.com/token", "", "",
"catalog", /*keep_refreshed=*/false, {}, client_));
ICEBERG_UNWRAP_OR_FAIL(
auto authenticated,
session->Authenticate({.headers = {{"Authorization", "Basic credentials"}}}));
EXPECT_EQ(authenticated.headers.at("Authorization"), "Basic credentials");
auto info = session->OAuth2Info();
ASSERT_TRUE(info.has_value());
EXPECT_EQ(info->issued_token_type, AuthProperties::kAccessTokenType);
}
TEST_F(AuthManagerTest, OAuth2SessionExposesMetadata) {
OAuthTokenResponse token_response{
.access_token = "parent-token",
.token_type = "bearer",
.issued_token_type = AuthProperties::kJwtTokenType,
};
ICEBERG_UNWRAP_OR_FAIL(
auto session,
AuthSession::MakeOAuth2(
token_response, "https://auth.example.com/token", "client-id", "client-secret",
"catalog", /*keep_refreshed=*/false,
{{AuthProperties::kAudience.key(), "catalog-audience"}}, client_));
auto info = session->OAuth2Info();
ASSERT_TRUE(info.has_value());
EXPECT_EQ(info->token, "parent-token");
EXPECT_EQ(info->issued_token_type, AuthProperties::kJwtTokenType);
EXPECT_EQ(info->credential, "client-id:client-secret");
EXPECT_EQ(info->scope, "catalog");
EXPECT_EQ(info->oauth2_server_uri, "https://auth.example.com/token");
EXPECT_EQ(info->optional_oauth_params.at(AuthProperties::kAudience.key()),
"catalog-audience");
}
TEST_F(AuthManagerTest, HttpClientRejectsParamsWhenUrlAlreadyHasQuery) {
auto session = AuthSession::MakeDefault({});
auto result =
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(
std::shared_ptr<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"},
{AuthProperties::kCredential.key(), "client-id:client-secret"},
{AuthProperties::kScope.key(), "catalog"},
{AuthProperties::kOAuth2ServerUri.key(), "https://auth.example.com/token"},
{AuthProperties::kAudience.key(), "catalog-audience"},
{AuthProperties::kResource.key(), "catalog-resource"},
};
auto manager_result = AuthManagers::Load("test-catalog", properties);
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");
auto info = session_result.value()->OAuth2Info();
ASSERT_TRUE(info.has_value());
EXPECT_EQ(info->token, "my-static-token");
EXPECT_EQ(info->issued_token_type, AuthProperties::kAccessTokenType);
EXPECT_EQ(info->credential, "client-id:client-secret");
EXPECT_EQ(info->scope, "catalog");
EXPECT_EQ(info->oauth2_server_uri, "https://auth.example.com/token");
EXPECT_EQ(info->optional_oauth_params.at(AuthProperties::kAudience.key()),
"catalog-audience");
EXPECT_EQ(info->optional_oauth_params.at(AuthProperties::kResource.key()),
"catalog-resource");
}
// Verifies OAuth2 type is inferred from token property
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());
auto info = session_result.value()->OAuth2Info();
ASSERT_TRUE(info.has_value());
EXPECT_TRUE(info->token.empty());
EXPECT_EQ(info->issued_token_type, AuthProperties::kAccessTokenType);
}
TEST_F(AuthManagerTest, OAuth2ContextTokenCreatesChildAndHasPriority) {
std::unordered_map<std::string, std::string> properties = {
{AuthProperties::kAuthType, "oauth2"},
{AuthProperties::kScope.key(), "catalog"},
{AuthProperties::kOAuth2ServerUri.key(), "https://auth.example.com/token"},
{AuthProperties::kAudience.key(), "catalog-audience"},
};
ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog", properties));
ICEBERG_UNWRAP_OR_FAIL(auto parent, manager->CatalogSession(client_, properties));
SessionContext context{
.session_id = "tenant-a",
.credentials =
{
{AuthProperties::kToken.key(), "context-token"},
{AuthProperties::kCredential.key(), "unused-credential"},
{AuthProperties::kIdTokenType, "unused-id-token"},
},
};
ICEBERG_UNWRAP_OR_FAIL(auto child, manager->ContextualSession(context, parent));
EXPECT_NE(child, parent);
ICEBERG_UNWRAP_OR_FAIL(auto authenticated, child->Authenticate({}));
EXPECT_EQ(authenticated.headers.at("Authorization"), "Bearer context-token");
auto info = child->OAuth2Info();
ASSERT_TRUE(info.has_value());
EXPECT_EQ(info->token, "context-token");
EXPECT_EQ(info->issued_token_type, AuthProperties::kAccessTokenType);
EXPECT_TRUE(info->credential.empty());
EXPECT_EQ(info->scope, "catalog");
EXPECT_EQ(info->oauth2_server_uri, "https://auth.example.com/token");
EXPECT_EQ(info->optional_oauth_params.at(AuthProperties::kAudience.key()),
"catalog-audience");
}
TEST_F(AuthManagerTest, OAuth2ContextTypedTokenOnlyUsesCredentials) {
std::unordered_map<std::string, std::string> properties = {
{AuthProperties::kAuthType, "oauth2"},
};
ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog", properties));
ICEBERG_UNWRAP_OR_FAIL(auto parent, manager->CatalogSession(client_, properties));
SessionContext context{
.session_id = "tenant-a",
.credentials = {{"unrelated", "value"}},
.properties = {{AuthProperties::kIdTokenType, "property-id-token"}},
};
ICEBERG_UNWRAP_OR_FAIL(auto child, manager->ContextualSession(context, parent));
EXPECT_EQ(child, parent);
}
TEST_F(AuthManagerTest, OAuth2TableTokenCreatesChild) {
std::unordered_map<std::string, std::string> properties = {
{AuthProperties::kAuthType, "oauth2"},
{AuthProperties::kScope.key(), "catalog"},
{AuthProperties::kOAuth2ServerUri.key(), "https://auth.example.com/token"},
};
ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog", properties));
ICEBERG_UNWRAP_OR_FAIL(auto parent, manager->CatalogSession(client_, properties));
TableIdentifier table{.ns = Namespace{{"db"}}, .name = "table"};
ICEBERG_UNWRAP_OR_FAIL(
auto child,
manager->TableSession(table,
{{AuthProperties::kToken.key(), "table-token"},
{AuthProperties::kCredential.key(), "ignored-credential"}},
parent));
EXPECT_NE(child, parent);
ICEBERG_UNWRAP_OR_FAIL(auto authenticated, child->Authenticate({}));
EXPECT_EQ(authenticated.headers.at("Authorization"), "Bearer table-token");
auto info = child->OAuth2Info();
ASSERT_TRUE(info.has_value());
EXPECT_EQ(info->issued_token_type, AuthProperties::kAccessTokenType);
EXPECT_TRUE(info->credential.empty());
}
TEST_F(AuthManagerTest, OAuth2TableIgnoresCredential) {
std::unordered_map<std::string, std::string> properties = {
{AuthProperties::kAuthType, "oauth2"},
};
ICEBERG_UNWRAP_OR_FAIL(auto manager, AuthManagers::Load("test-catalog", properties));
ICEBERG_UNWRAP_OR_FAIL(auto parent, manager->CatalogSession(client_, properties));
TableIdentifier table{.ns = Namespace{{"db"}}, .name = "table"};
ICEBERG_UNWRAP_OR_FAIL(
auto child,
manager->TableSession(
table, {{AuthProperties::kCredential.key(), "ignored-credential"}}, parent));
EXPECT_EQ(child, parent);
}
// Verifies that when both token and credential are provided, token takes priority
// 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");
}
// ---- OAuth2Util expiry tests ----
TEST_F(AuthManagerTest, ExpiresAtMillisValidJwt) {
std::string token = MakeJwt(R"({"sub":"user","exp":1700000000})");
auto result = OAuth2Util::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(OAuth2Util::ExpiresAtMillis(token).has_value()) << token;
}
}
// ---- OAuth2Session tests ----
namespace {
class RefreshHttpClient : public HttpClient {
public:
Result<HttpResponse> PostForm(const std::string&,
const std::unordered_map<std::string, std::string>&,
const std::unordered_map<std::string, std::string>&,
const ErrorHandler&, AuthSession&) override {
++requests;
return response();
}
std::function<Result<HttpResponse>()> response = [] {
return IOError("refresh failed");
};
std::atomic<int> requests{0};
};
using test::MockExecutor;
// Already expired, so the session schedules its first refresh immediately.
OAuthTokenResponse ExpiredInitialToken() {
return {.access_token = "initial", .token_type = "bearer", .expires_in_secs = 0};
}
Result<std::shared_ptr<AuthSession>> RefreshingSession(
const std::shared_ptr<HttpClient>& client, Executor& executor) {
return AuthSession::MakeOAuth2(ExpiredInitialToken(), "https://auth.example/token",
"client", "secret", "catalog", true, {}, client,
&executor);
}
} // namespace
TEST(OAuth2SessionTest, RefreshesAndReschedulesThroughPublicFactory) {
MockExecutor executor;
std::array<std::promise<ExecutorTask>, 2> queued;
int submitted = 0;
EXPECT_CALL(executor, Submit).Times(2).WillRepeatedly([&](ExecutorTask task) -> Status {
queued.at(submitted++).set_value(std::move(task));
return {};
});
auto client = std::make_shared<RefreshHttpClient>();
client->response = [&]() -> Result<HttpResponse> {
return HttpResponse(
200, client->requests == 1
? R"({"access_token":"first","token_type":"bearer","expires_in":1})"
: R"({"access_token":"second","token_type":"bearer"})");
};
ICEBERG_UNWRAP_OR_FAIL(auto session, RefreshingSession(client, executor));
for (auto& slot : queued) {
auto task = slot.get_future();
ASSERT_EQ(task.wait_for(std::chrono::seconds(5)), std::future_status::ready);
std::move(task.get())();
}
ASSERT_THAT(session->Close(), IsOk());
ICEBERG_UNWRAP_OR_FAIL(auto request, session->Authenticate({}));
EXPECT_EQ(request.headers.at("Authorization"), "Bearer second");
EXPECT_EQ(client->requests, 2);
}
TEST(OAuth2SessionTest, AlreadyExpiredRefreshedTokenBacksOff) {
MockExecutor executor;
std::promise<ExecutorTask> queued;
auto task = queued.get_future();
EXPECT_CALL(executor, Submit).WillOnce([&](ExecutorTask work) -> Status {
queued.set_value(std::move(work));
return {};
});
auto client = std::make_shared<RefreshHttpClient>();
client->response = [&]() -> Result<HttpResponse> {
return HttpResponse(
200, client->requests == 1
? R"({"access_token":"stale","token_type":"bearer","expires_in":0})"
: R"({"access_token":"fresh","token_type":"bearer"})");
};
ICEBERG_UNWRAP_OR_FAIL(auto session, RefreshingSession(client, executor));
ASSERT_EQ(task.wait_for(std::chrono::seconds(5)), std::future_status::ready);
test::FakeRetryEnvironment fake_retry;
ScopedRetryTestHooks hooks(fake_retry.hooks());
std::move(task.get())();
EXPECT_THAT(fake_retry.sleep_durations(),
::testing::ElementsAre(std::chrono::milliseconds(100)));
EXPECT_EQ(client->requests, 2);
ICEBERG_UNWRAP_OR_FAIL(auto request, session->Authenticate({}));
EXPECT_EQ(request.headers.at("Authorization"), "Bearer fresh");
ASSERT_THAT(session->Close(), IsOk());
}
TEST(OAuth2SessionTest, ThrowingRefreshIsRetried) {
MockExecutor executor;
std::promise<ExecutorTask> queued;
auto task = queued.get_future();
EXPECT_CALL(executor, Submit).WillOnce([&](ExecutorTask work) -> Status {
queued.set_value(std::move(work));
return {};
});
auto client = std::make_shared<RefreshHttpClient>();
client->response = [&]() -> Result<HttpResponse> {
if (client->requests == 1) {
throw std::runtime_error("HTTP library failure");
}
return HttpResponse(200, R"({"access_token":"recovered","token_type":"bearer"})");
};
ICEBERG_UNWRAP_OR_FAIL(auto session, RefreshingSession(client, executor));
ASSERT_EQ(task.wait_for(std::chrono::seconds(5)), std::future_status::ready);
test::FakeRetryEnvironment fake_retry;
ScopedRetryTestHooks hooks(fake_retry.hooks());
std::move(task.get())();
ICEBERG_UNWRAP_OR_FAIL(auto request, session->Authenticate({}));
EXPECT_EQ(request.headers.at("Authorization"), "Bearer recovered");
EXPECT_EQ(client->requests, 2);
ASSERT_THAT(session->Close(), IsOk());
}
TEST(OAuth2SessionTest, CloseSkipsAlreadyQueuedRefresh) {
MockExecutor executor;
std::promise<ExecutorTask> queued;
auto task = queued.get_future();
EXPECT_CALL(executor, Submit).WillOnce([&](ExecutorTask work) -> Status {
queued.set_value(std::move(work));
return {};
});
auto client = std::make_shared<RefreshHttpClient>();
ICEBERG_UNWRAP_OR_FAIL(auto session, RefreshingSession(client, executor));
ASSERT_EQ(task.wait_for(std::chrono::seconds(5)), std::future_status::ready);
ASSERT_THAT(session->Close(), IsOk());
std::move(task.get())();
EXPECT_EQ(client->requests, 0);
}
TEST(OAuth2SessionTest, ConcurrentCloseCallsWaitForCurrentHttpRequest) {
MockExecutor executor;
std::promise<ExecutorTask> queued;
auto task = queued.get_future();
EXPECT_CALL(executor, Submit).WillOnce([&](ExecutorTask work) -> Status {
queued.set_value(std::move(work));
return {};
});
auto client = std::make_shared<RefreshHttpClient>();
std::promise<void> entered;
std::promise<void> release;
auto entered_future = entered.get_future();
auto release_future = release.get_future().share();
client->response = [&]() -> Result<HttpResponse> {
entered.set_value();
release_future.wait();
// Closing the session must stop retries of this failed request.
return IOError("refresh failed during Close");
};
ICEBERG_UNWRAP_OR_FAIL(auto session, RefreshingSession(client, executor));
ASSERT_EQ(task.wait_for(std::chrono::seconds(5)), std::future_status::ready);
auto refresh = std::async(std::launch::async, [&] { std::move(task.get())(); });
auto entered_status = entered_future.wait_for(std::chrono::seconds(5));
if (entered_status != std::future_status::ready) {
release.set_value();
}
ASSERT_EQ(entered_status, std::future_status::ready);
auto first = std::async(std::launch::async, [&] { return session->Close(); });
auto second = std::async(std::launch::async, [&] { return session->Close(); });
EXPECT_EQ(first.wait_for(std::chrono::milliseconds(30)), std::future_status::timeout);
EXPECT_EQ(second.wait_for(std::chrono::milliseconds(30)), std::future_status::timeout);
release.set_value();
refresh.get();
ASSERT_THAT(first.get(), IsOk());
ASSERT_THAT(second.get(), IsOk());
EXPECT_EQ(client->requests, 1);
}
TEST(OAuth2SessionTest, StopsAfterHttpRetryBudget) {
MockExecutor executor;
std::promise<ExecutorTask> queued;
auto task = queued.get_future();
EXPECT_CALL(executor, Submit).WillOnce([&](ExecutorTask work) -> Status {
queued.set_value(std::move(work));
return {};
});
auto client = std::make_shared<RefreshHttpClient>();
ICEBERG_UNWRAP_OR_FAIL(auto session, RefreshingSession(client, executor));
ASSERT_EQ(task.wait_for(std::chrono::seconds(5)), std::future_status::ready);
test::FakeRetryEnvironment fake_retry;
ScopedRetryTestHooks hooks(fake_retry.hooks());
std::move(task.get())();
EXPECT_EQ(client->requests, 6);
EXPECT_THAT(fake_retry.observed_base_delays_ms(),
::testing::ElementsAre(100, 200, 400, 800, 1600));
ICEBERG_UNWRAP_OR_FAIL(auto request, session->Authenticate({}));
EXPECT_EQ(request.headers.at("Authorization"), "Bearer initial");
ASSERT_THAT(session->Close(), IsOk());
}
TEST(OAuth2SessionTest, InitTokenExpirationUsesRequestStartTime) {
OAuthTokenResponse token_response{
.access_token = "opaque-token",
.token_type = "bearer",
.expires_in_secs = 60,
};
auto request_started_at = std::chrono::steady_clock::time_point{};
auto expiration = internal::TokenExpirationTime(token_response, request_started_at);
ASSERT_TRUE(expiration.has_value());
EXPECT_EQ(*expiration, request_started_at + std::chrono::seconds(60));
}
TEST(OAuth2SessionTest, JwtExpirationTakesPriorityOverExpiresIn) {
OAuthTokenResponse token_response{
.access_token = MakeJwt(R"({"exp":120})"),
.token_type = "bearer",
.expires_in_secs = 60,
};
auto request_started_at = std::chrono::steady_clock::time_point{};
auto now_system = std::chrono::system_clock::time_point(std::chrono::seconds(100));
auto now_steady = std::chrono::steady_clock::time_point(std::chrono::seconds(50));
auto expiration = internal::TokenExpirationTime(token_response, request_started_at,
now_system, now_steady);
ASSERT_TRUE(expiration.has_value());
EXPECT_EQ(*expiration, std::chrono::steady_clock::time_point(std::chrono::seconds(70)));
}
} // namespace iceberg::rest::auth