Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/iceberg/catalog/rest/auth/auth_properties.cc
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@

#include <utility>

#include <nlohmann/json.hpp>

#include "iceberg/catalog/rest/catalog_properties.h"
#include "iceberg/util/macros.h"
#include "iceberg/util/transform_util.h"

namespace iceberg::rest::auth {

Expand Down Expand Up @@ -75,7 +79,25 @@ Result<AuthProperties> AuthProperties::FromProperties(
}
}

// TODO(lishuxu): Parse JWT exp claim from token to set expires_at_millis_.
// Parse JWT exp claim from token to set expires_at_millis_.
if (auto token = config.token(); !token.empty()) {
auto first_dot = token.find('.');
auto last_dot = token.find('.', first_dot + 1);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: last_dot is a bit misleading — it could imply rfind was used.
Since JWT has exactly three parts, second_dot is more precise.

if (first_dot != std::string::npos && last_dot != std::string::npos) {
auto payload_encoded = token.substr(first_dot + 1, last_dot - first_dot - 1);
auto payload_decoded = TransformUtil::Base64UrlDecode(payload_encoded);
if (payload_decoded.has_value()) {
try {
auto payload_json = nlohmann::json::parse(payload_decoded.value());
if (payload_json.contains("exp") && payload_json["exp"].is_number()) {
config.expires_at_millis_ = payload_json["exp"].get<int64_t>() * 1000;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_number() returns true for both integers and floats, but get<int64_t>()
throws nlohmann::json::type_error when the value is a float (e.g. "exp": 1735689600.0).
This exception is not caught by the parse_error handler, so it would propagate
unexpectedly.

}
} catch (const nlohmann::json::parse_error& e) {
// Ignore parse errors from invalid JWT payloads.
}
}
}
}

return config;
}
Expand Down
3 changes: 3 additions & 0 deletions src/iceberg/catalog/rest/auth/auth_properties.h
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ class ICEBERG_REST_EXPORT AuthProperties : public ConfigBase<AuthProperties> {
/// \brief Build optional OAuth params (audience, resource) from config.
std::unordered_map<std::string, std::string> optional_oauth_params() const;

/// \brief Get the token expiration time in milliseconds.
std::optional<int64_t> expires_at_millis() const { return expires_at_millis_; }

private:
std::string client_id_;
std::string client_secret_;
Expand Down
25 changes: 25 additions & 0 deletions src/iceberg/test/auth_manager_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -358,4 +358,29 @@ TEST_F(AuthManagerTest, OAuthTokenResponseNATokenType) {
EXPECT_EQ(result->token_type, "N_A");
}

// Verifies that JWT exp claim is parsed from token in AuthProperties
TEST_F(AuthManagerTest, AuthPropertiesParseJwtExp) {
// Payload: {"exp": 1735689600} (2025-01-01 00:00:00 UTC)
// Base64Url(payload): "eyJleHAiOiAxNzM1Njg5NjAwfQ"
std::string token = "header.eyJleHAiOiAxNzM1Njg5NjAwfQ.signature";
std::unordered_map<std::string, std::string> properties = {
{AuthProperties::kToken.key(), token}};

auto config_result = AuthProperties::FromProperties(properties);
ASSERT_THAT(config_result, IsOk());
ASSERT_TRUE(config_result->expires_at_millis().has_value());
EXPECT_EQ(config_result->expires_at_millis().value(), 1735689600000LL);
}

// Verifies that invalid JWT doesn't set expiration
TEST_F(AuthManagerTest, AuthPropertiesInvalidJwtNoExp) {
std::string token = "invalid-token-no-dots";
std::unordered_map<std::string, std::string> properties = {
{AuthProperties::kToken.key(), token}};

auto config_result = AuthProperties::FromProperties(properties);
ASSERT_THAT(config_result, IsOk());
EXPECT_FALSE(config_result->expires_at_millis().has_value());
}

} // namespace iceberg::rest::auth
10 changes: 7 additions & 3 deletions src/iceberg/test/matchers.h
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,14 @@ class HasValueMatcher {

void DescribeTo(std::ostream* os) const {
*os << "has a value that ";
matcher_.DescribeTo(os);
::testing::internal::DescribeMatcher<const typename MatcherT::value_type&>(
matcher_, os);
}

void DescribeNegationTo(std::ostream* os) const {
*os << "does not have a value that ";
matcher_.DescribeTo(os);
::testing::internal::DescribeMatcher<const typename MatcherT::value_type&>(
matcher_, os);
}

private:
Expand All @@ -140,7 +142,9 @@ class HasValueMatcher {
template <typename MatcherT>
auto HasValue(MatcherT&& matcher) {
return ::testing::MakePolymorphicMatcher(
HasValueMatcher<std::remove_cvref_t<MatcherT>>(std::forward<MatcherT>(matcher)));
HasValueMatcher<::testing::Matcher<const std::remove_cvref_t<MatcherT>&>>(
::testing::SafeMatcherCast<const std::remove_cvref_t<MatcherT>&>(
std::forward<MatcherT>(matcher))));
}

// Overload for the common case where we just want to check for presence of any value
Expand Down
33 changes: 33 additions & 0 deletions src/iceberg/test/transform_util_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,39 @@ TEST(TransformUtilTest, Base64Encode) {
EXPECT_EQ("AA==", TransformUtil::Base64Encode({"\x00", 1}));
}

TEST(TransformUtilTest, Base64UrlDecode) {
// Empty string
EXPECT_THAT(TransformUtil::Base64UrlDecode(""), HasValue(std::string("")));

// No padding
EXPECT_THAT(TransformUtil::Base64UrlDecode("YQ"), HasValue(std::string("a")));
EXPECT_THAT(TransformUtil::Base64UrlDecode("YWI"), HasValue(std::string("ab")));
EXPECT_THAT(TransformUtil::Base64UrlDecode("YWJj"), HasValue(std::string("abc")));

// RFC 4648 test vectors
EXPECT_THAT(TransformUtil::Base64UrlDecode("Zg"), HasValue(std::string("f")));
EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm8"), HasValue(std::string("fo")));
EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9v"), HasValue(std::string("foo")));
EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9vYg"), HasValue(std::string("foob")));
EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9vYmE"), HasValue(std::string("fooba")));
EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9vYmFy"),
HasValue(std::string("foobar")));

// Base64Url specific characters
// "-" -> 62, "_" -> 63
// ">>?" -> Base64: "Pj4/" -> Base64Url: "Pj4_"
EXPECT_THAT(TransformUtil::Base64UrlDecode("Pj4_"), HasValue(std::string(">>?")));
// "?>>" -> Base64: "Pz4+" -> Base64Url: "Pz4-"
EXPECT_THAT(TransformUtil::Base64UrlDecode("Pz4-"), HasValue(std::string("?>>")));

// Padding should be handled if present (though JWT omits it)
EXPECT_THAT(TransformUtil::Base64UrlDecode("YQ=="), HasValue(std::string("a")));

// Invalid characters
EXPECT_THAT(TransformUtil::Base64UrlDecode("YQ*"),
IsError(ErrorKind::kInvalidArgument));
}

struct ParseRoundTripParam {
std::string name;
std::string str;
Expand Down
36 changes: 36 additions & 0 deletions src/iceberg/util/transform_util.cc
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,40 @@ std::string TransformUtil::Base64Encode(std::string_view str_to_encode) {
return encoded;
}

Result<std::string> TransformUtil::Base64UrlDecode(std::string_view str_to_decode) {
std::string decoded;
decoded.reserve(str_to_decode.size() * 3 / 4);

uint32_t val = 0;
int32_t bits = 0;
for (char c : str_to_decode) {
if (c == '=') break;
int8_t v = -1;
if (c >= 'A' && c <= 'Z')
v = static_cast<int8_t>(c - 'A');
else if (c >= 'a' && c <= 'z')
v = static_cast<int8_t>(c - 'a' + 26);
else if (c >= '0' && c <= '9')
v = static_cast<int8_t>(c - '0' + 52);
else if (c == '-' || c == '+')
v = 62;
else if (c == '_' || c == '/')
v = 63;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Base64UrlDecode accepts + and / which are standard Base64 characters, not Base64Url. This means inputs that should be rejected are silently accepted, which contradicts the function name and the error message.

Base64Url spec only uses - (62) and _ (63). + and / should fall through to the invalid character check.

Suggested fix:

  else if (c == '-')
    v = 62;
  else if (c == '_')
    v = 63;


if (v == -1) {
return InvalidArgument("Invalid character in Base64Url string: '{}'", c);
}

val = (val << 6) | static_cast<uint32_t>(v);
bits += 6;

if (bits >= 8) {
bits -= 8;
decoded.push_back(static_cast<char>((val >> bits) & 0xFF));
val &= (1U << bits) - 1;
}
}
return decoded;
}

} // namespace iceberg
3 changes: 3 additions & 0 deletions src/iceberg/util/transform_util.h
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ class ICEBERG_EXPORT TransformUtil {

/// \brief Base64 encode a string
static std::string Base64Encode(std::string_view str_to_encode);

/// \brief Base64Url decode a string
static Result<std::string> Base64UrlDecode(std::string_view str_to_decode);
};

} // namespace iceberg
Loading