From f1560580c70d8cfdca21a418ca3580859e1b8b76 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 02:38:08 +0000 Subject: [PATCH 1/3] feat: Parse JWT exp claim from token in AuthProperties - Implement Base64UrlDecode in TransformUtil. - Update AuthProperties::FromProperties to parse the JWT exp claim from the token and set expires_at_millis_. - Add expires_at_millis() getter to AuthProperties. - Add unit tests for Base64UrlDecode and JWT expiration parsing. Co-authored-by: wgtmac <4684607+wgtmac@users.noreply.github.com> --- .../catalog/rest/auth/auth_properties.cc | 24 ++++++++++++- .../catalog/rest/auth/auth_properties.h | 3 ++ src/iceberg/test/auth_manager_test.cc | 25 +++++++++++++ src/iceberg/test/transform_util_test.cc | 32 +++++++++++++++++ src/iceberg/util/transform_util.cc | 36 +++++++++++++++++++ src/iceberg/util/transform_util.h | 3 ++ 6 files changed, 122 insertions(+), 1 deletion(-) diff --git a/src/iceberg/catalog/rest/auth/auth_properties.cc b/src/iceberg/catalog/rest/auth/auth_properties.cc index dcf16782c..c693fc5dd 100644 --- a/src/iceberg/catalog/rest/auth/auth_properties.cc +++ b/src/iceberg/catalog/rest/auth/auth_properties.cc @@ -21,7 +21,11 @@ #include +#include + #include "iceberg/catalog/rest/catalog_properties.h" +#include "iceberg/util/macros.h" +#include "iceberg/util/transform_util.h" namespace iceberg::rest::auth { @@ -75,7 +79,25 @@ Result 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); + 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() * 1000; + } + } catch (const nlohmann::json::parse_error& e) { + // Ignore parse errors from invalid JWT payloads. + } + } + } + } return config; } diff --git a/src/iceberg/catalog/rest/auth/auth_properties.h b/src/iceberg/catalog/rest/auth/auth_properties.h index 05a7ea2c6..fd1796108 100644 --- a/src/iceberg/catalog/rest/auth/auth_properties.h +++ b/src/iceberg/catalog/rest/auth/auth_properties.h @@ -96,6 +96,9 @@ class ICEBERG_REST_EXPORT AuthProperties : public ConfigBase { /// \brief Build optional OAuth params (audience, resource) from config. std::unordered_map optional_oauth_params() const; + /// \brief Get the token expiration time in milliseconds. + std::optional expires_at_millis() const { return expires_at_millis_; } + private: std::string client_id_; std::string client_secret_; diff --git a/src/iceberg/test/auth_manager_test.cc b/src/iceberg/test/auth_manager_test.cc index bd06fee3f..23f46becc 100644 --- a/src/iceberg/test/auth_manager_test.cc +++ b/src/iceberg/test/auth_manager_test.cc @@ -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 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 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 diff --git a/src/iceberg/test/transform_util_test.cc b/src/iceberg/test/transform_util_test.cc index 54f36cd07..1dc33412b 100644 --- a/src/iceberg/test/transform_util_test.cc +++ b/src/iceberg/test/transform_util_test.cc @@ -159,6 +159,38 @@ TEST(TransformUtilTest, Base64Encode) { EXPECT_EQ("AA==", TransformUtil::Base64Encode({"\x00", 1})); } +TEST(TransformUtilTest, Base64UrlDecode) { + // Empty string + EXPECT_THAT(TransformUtil::Base64UrlDecode(""), IsOkAndEq("")); + + // No padding + EXPECT_THAT(TransformUtil::Base64UrlDecode("YQ"), IsOkAndEq("a")); + EXPECT_THAT(TransformUtil::Base64UrlDecode("YWI"), IsOkAndEq("ab")); + EXPECT_THAT(TransformUtil::Base64UrlDecode("YWJj"), IsOkAndEq("abc")); + + // RFC 4648 test vectors + EXPECT_THAT(TransformUtil::Base64UrlDecode("Zg"), IsOkAndEq("f")); + EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm8"), IsOkAndEq("fo")); + EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9v"), IsOkAndEq("foo")); + EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9vYg"), IsOkAndEq("foob")); + EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9vYmE"), IsOkAndEq("fooba")); + EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9vYmFy"), IsOkAndEq("foobar")); + + // Base64Url specific characters + // "-" -> 62, "_" -> 63 + // ">>?" -> Base64: "Pj4/" -> Base64Url: "Pj4_" + EXPECT_THAT(TransformUtil::Base64UrlDecode("Pj4_"), IsOkAndEq(">>?")); + // "?>>" -> Base64: "Pz4+" -> Base64Url: "Pz4-" + EXPECT_THAT(TransformUtil::Base64UrlDecode("Pz4-"), IsOkAndEq("?>>")); + + // Padding should be handled if present (though JWT omits it) + EXPECT_THAT(TransformUtil::Base64UrlDecode("YQ=="), IsOkAndEq("a")); + + // Invalid characters + EXPECT_THAT(TransformUtil::Base64UrlDecode("YQ*"), + IsError(ErrorKind::kInvalidArgument)); +} + struct ParseRoundTripParam { std::string name; std::string str; diff --git a/src/iceberg/util/transform_util.cc b/src/iceberg/util/transform_util.cc index a9221310e..abc63f347 100644 --- a/src/iceberg/util/transform_util.cc +++ b/src/iceberg/util/transform_util.cc @@ -283,4 +283,40 @@ std::string TransformUtil::Base64Encode(std::string_view str_to_encode) { return encoded; } +Result 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(c - 'A'); + else if (c >= 'a' && c <= 'z') + v = static_cast(c - 'a' + 26); + else if (c >= '0' && c <= '9') + v = static_cast(c - '0' + 52); + else if (c == '-' || c == '+') + v = 62; + else if (c == '_' || c == '/') + v = 63; + + if (v == -1) { + return InvalidArgument("Invalid character in Base64Url string: '{}'", c); + } + + val = (val << 6) | static_cast(v); + bits += 6; + + if (bits >= 8) { + bits -= 8; + decoded.push_back(static_cast((val >> bits) & 0xFF)); + val &= (1U << bits) - 1; + } + } + return decoded; +} + } // namespace iceberg diff --git a/src/iceberg/util/transform_util.h b/src/iceberg/util/transform_util.h index c23d08c8c..e148816b6 100644 --- a/src/iceberg/util/transform_util.h +++ b/src/iceberg/util/transform_util.h @@ -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 Base64UrlDecode(std::string_view str_to_decode); }; } // namespace iceberg From f4dff4ffaa2ea6a60f160d403086af5ac57c0057 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 03:22:21 +0000 Subject: [PATCH 2/3] feat: Parse JWT exp claim from token in AuthProperties - Implement robust Base64UrlDecode in TransformUtil using unsigned integers to avoid undefined behavior. - Update AuthProperties::FromProperties to parse the JWT exp claim from the token and set expires_at_millis_. - Add expires_at_millis() getter to AuthProperties. - Add unit tests for Base64UrlDecode and JWT expiration parsing, using standard matchers and explicit type casting for Windows/MSVC compatibility. Co-authored-by: wgtmac <4684607+wgtmac@users.noreply.github.com> --- src/iceberg/test/transform_util_test.cc | 26 ++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/iceberg/test/transform_util_test.cc b/src/iceberg/test/transform_util_test.cc index 1dc33412b..f121514d0 100644 --- a/src/iceberg/test/transform_util_test.cc +++ b/src/iceberg/test/transform_util_test.cc @@ -161,30 +161,30 @@ TEST(TransformUtilTest, Base64Encode) { TEST(TransformUtilTest, Base64UrlDecode) { // Empty string - EXPECT_THAT(TransformUtil::Base64UrlDecode(""), IsOkAndEq("")); + EXPECT_THAT(TransformUtil::Base64UrlDecode(""), HasValue(std::string(""))); // No padding - EXPECT_THAT(TransformUtil::Base64UrlDecode("YQ"), IsOkAndEq("a")); - EXPECT_THAT(TransformUtil::Base64UrlDecode("YWI"), IsOkAndEq("ab")); - EXPECT_THAT(TransformUtil::Base64UrlDecode("YWJj"), IsOkAndEq("abc")); + 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"), IsOkAndEq("f")); - EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm8"), IsOkAndEq("fo")); - EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9v"), IsOkAndEq("foo")); - EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9vYg"), IsOkAndEq("foob")); - EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9vYmE"), IsOkAndEq("fooba")); - EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9vYmFy"), IsOkAndEq("foobar")); + 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_"), IsOkAndEq(">>?")); + EXPECT_THAT(TransformUtil::Base64UrlDecode("Pj4_"), HasValue(std::string(">>?"))); // "?>>" -> Base64: "Pz4+" -> Base64Url: "Pz4-" - EXPECT_THAT(TransformUtil::Base64UrlDecode("Pz4-"), IsOkAndEq("?>>")); + EXPECT_THAT(TransformUtil::Base64UrlDecode("Pz4-"), HasValue(std::string("?>>"))); // Padding should be handled if present (though JWT omits it) - EXPECT_THAT(TransformUtil::Base64UrlDecode("YQ=="), IsOkAndEq("a")); + EXPECT_THAT(TransformUtil::Base64UrlDecode("YQ=="), HasValue(std::string("a"))); // Invalid characters EXPECT_THAT(TransformUtil::Base64UrlDecode("YQ*"), From 44940ce71b3c313eaf00585f2f529c3ebd782096 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:58:01 +0000 Subject: [PATCH 3/3] feat: Parse JWT exp claim from token in AuthProperties - Implement robust Base64UrlDecode in TransformUtil using unsigned integers to avoid undefined behavior. - Update AuthProperties::FromProperties to parse the JWT exp claim from the token and set expires_at_millis_. - Add expires_at_millis() getter to AuthProperties. - Add unit tests for Base64UrlDecode and JWT expiration parsing. - Improve HasValue matcher to support non-matcher arguments and fix MSVC template issues by using SafeMatcherCast and DescribeMatcher. Co-authored-by: wgtmac <4684607+wgtmac@users.noreply.github.com> --- src/iceberg/test/matchers.h | 10 +++++++--- src/iceberg/test/transform_util_test.cc | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/iceberg/test/matchers.h b/src/iceberg/test/matchers.h index f8c703082..1e606cb22 100644 --- a/src/iceberg/test/matchers.h +++ b/src/iceberg/test/matchers.h @@ -124,12 +124,14 @@ class HasValueMatcher { void DescribeTo(std::ostream* os) const { *os << "has a value that "; - matcher_.DescribeTo(os); + ::testing::internal::DescribeMatcher( + matcher_, os); } void DescribeNegationTo(std::ostream* os) const { *os << "does not have a value that "; - matcher_.DescribeTo(os); + ::testing::internal::DescribeMatcher( + matcher_, os); } private: @@ -140,7 +142,9 @@ class HasValueMatcher { template auto HasValue(MatcherT&& matcher) { return ::testing::MakePolymorphicMatcher( - HasValueMatcher>(std::forward(matcher))); + HasValueMatcher<::testing::Matcher&>>( + ::testing::SafeMatcherCast&>( + std::forward(matcher)))); } // Overload for the common case where we just want to check for presence of any value diff --git a/src/iceberg/test/transform_util_test.cc b/src/iceberg/test/transform_util_test.cc index f121514d0..808cfcc8b 100644 --- a/src/iceberg/test/transform_util_test.cc +++ b/src/iceberg/test/transform_util_test.cc @@ -174,7 +174,8 @@ TEST(TransformUtilTest, Base64UrlDecode) { 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"))); + EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9vYmFy"), + HasValue(std::string("foobar"))); // Base64Url specific characters // "-" -> 62, "_" -> 63