Skip to content

Commit f156058

Browse files
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>
1 parent 0596ef5 commit f156058

File tree

6 files changed

+122
-1
lines changed

6 files changed

+122
-1
lines changed

src/iceberg/catalog/rest/auth/auth_properties.cc

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121

2222
#include <utility>
2323

24+
#include <nlohmann/json.hpp>
25+
2426
#include "iceberg/catalog/rest/catalog_properties.h"
27+
#include "iceberg/util/macros.h"
28+
#include "iceberg/util/transform_util.h"
2529

2630
namespace iceberg::rest::auth {
2731

@@ -75,7 +79,25 @@ Result<AuthProperties> AuthProperties::FromProperties(
7579
}
7680
}
7781

78-
// TODO(lishuxu): Parse JWT exp claim from token to set expires_at_millis_.
82+
// Parse JWT exp claim from token to set expires_at_millis_.
83+
if (auto token = config.token(); !token.empty()) {
84+
auto first_dot = token.find('.');
85+
auto last_dot = token.find('.', first_dot + 1);
86+
if (first_dot != std::string::npos && last_dot != std::string::npos) {
87+
auto payload_encoded = token.substr(first_dot + 1, last_dot - first_dot - 1);
88+
auto payload_decoded = TransformUtil::Base64UrlDecode(payload_encoded);
89+
if (payload_decoded.has_value()) {
90+
try {
91+
auto payload_json = nlohmann::json::parse(payload_decoded.value());
92+
if (payload_json.contains("exp") && payload_json["exp"].is_number()) {
93+
config.expires_at_millis_ = payload_json["exp"].get<int64_t>() * 1000;
94+
}
95+
} catch (const nlohmann::json::parse_error& e) {
96+
// Ignore parse errors from invalid JWT payloads.
97+
}
98+
}
99+
}
100+
}
79101

80102
return config;
81103
}

src/iceberg/catalog/rest/auth/auth_properties.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ class ICEBERG_REST_EXPORT AuthProperties : public ConfigBase<AuthProperties> {
9696
/// \brief Build optional OAuth params (audience, resource) from config.
9797
std::unordered_map<std::string, std::string> optional_oauth_params() const;
9898

99+
/// \brief Get the token expiration time in milliseconds.
100+
std::optional<int64_t> expires_at_millis() const { return expires_at_millis_; }
101+
99102
private:
100103
std::string client_id_;
101104
std::string client_secret_;

src/iceberg/test/auth_manager_test.cc

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,4 +358,29 @@ TEST_F(AuthManagerTest, OAuthTokenResponseNATokenType) {
358358
EXPECT_EQ(result->token_type, "N_A");
359359
}
360360

361+
// Verifies that JWT exp claim is parsed from token in AuthProperties
362+
TEST_F(AuthManagerTest, AuthPropertiesParseJwtExp) {
363+
// Payload: {"exp": 1735689600} (2025-01-01 00:00:00 UTC)
364+
// Base64Url(payload): "eyJleHAiOiAxNzM1Njg5NjAwfQ"
365+
std::string token = "header.eyJleHAiOiAxNzM1Njg5NjAwfQ.signature";
366+
std::unordered_map<std::string, std::string> properties = {
367+
{AuthProperties::kToken.key(), token}};
368+
369+
auto config_result = AuthProperties::FromProperties(properties);
370+
ASSERT_THAT(config_result, IsOk());
371+
ASSERT_TRUE(config_result->expires_at_millis().has_value());
372+
EXPECT_EQ(config_result->expires_at_millis().value(), 1735689600000LL);
373+
}
374+
375+
// Verifies that invalid JWT doesn't set expiration
376+
TEST_F(AuthManagerTest, AuthPropertiesInvalidJwtNoExp) {
377+
std::string token = "invalid-token-no-dots";
378+
std::unordered_map<std::string, std::string> properties = {
379+
{AuthProperties::kToken.key(), token}};
380+
381+
auto config_result = AuthProperties::FromProperties(properties);
382+
ASSERT_THAT(config_result, IsOk());
383+
EXPECT_FALSE(config_result->expires_at_millis().has_value());
384+
}
385+
361386
} // namespace iceberg::rest::auth

src/iceberg/test/transform_util_test.cc

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,38 @@ TEST(TransformUtilTest, Base64Encode) {
159159
EXPECT_EQ("AA==", TransformUtil::Base64Encode({"\x00", 1}));
160160
}
161161

162+
TEST(TransformUtilTest, Base64UrlDecode) {
163+
// Empty string
164+
EXPECT_THAT(TransformUtil::Base64UrlDecode(""), IsOkAndEq(""));
165+
166+
// No padding
167+
EXPECT_THAT(TransformUtil::Base64UrlDecode("YQ"), IsOkAndEq("a"));
168+
EXPECT_THAT(TransformUtil::Base64UrlDecode("YWI"), IsOkAndEq("ab"));
169+
EXPECT_THAT(TransformUtil::Base64UrlDecode("YWJj"), IsOkAndEq("abc"));
170+
171+
// RFC 4648 test vectors
172+
EXPECT_THAT(TransformUtil::Base64UrlDecode("Zg"), IsOkAndEq("f"));
173+
EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm8"), IsOkAndEq("fo"));
174+
EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9v"), IsOkAndEq("foo"));
175+
EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9vYg"), IsOkAndEq("foob"));
176+
EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9vYmE"), IsOkAndEq("fooba"));
177+
EXPECT_THAT(TransformUtil::Base64UrlDecode("Zm9vYmFy"), IsOkAndEq("foobar"));
178+
179+
// Base64Url specific characters
180+
// "-" -> 62, "_" -> 63
181+
// ">>?" -> Base64: "Pj4/" -> Base64Url: "Pj4_"
182+
EXPECT_THAT(TransformUtil::Base64UrlDecode("Pj4_"), IsOkAndEq(">>?"));
183+
// "?>>" -> Base64: "Pz4+" -> Base64Url: "Pz4-"
184+
EXPECT_THAT(TransformUtil::Base64UrlDecode("Pz4-"), IsOkAndEq("?>>"));
185+
186+
// Padding should be handled if present (though JWT omits it)
187+
EXPECT_THAT(TransformUtil::Base64UrlDecode("YQ=="), IsOkAndEq("a"));
188+
189+
// Invalid characters
190+
EXPECT_THAT(TransformUtil::Base64UrlDecode("YQ*"),
191+
IsError(ErrorKind::kInvalidArgument));
192+
}
193+
162194
struct ParseRoundTripParam {
163195
std::string name;
164196
std::string str;

src/iceberg/util/transform_util.cc

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,4 +283,40 @@ std::string TransformUtil::Base64Encode(std::string_view str_to_encode) {
283283
return encoded;
284284
}
285285

286+
Result<std::string> TransformUtil::Base64UrlDecode(std::string_view str_to_decode) {
287+
std::string decoded;
288+
decoded.reserve(str_to_decode.size() * 3 / 4);
289+
290+
uint32_t val = 0;
291+
int32_t bits = 0;
292+
for (char c : str_to_decode) {
293+
if (c == '=') break;
294+
int8_t v = -1;
295+
if (c >= 'A' && c <= 'Z')
296+
v = static_cast<int8_t>(c - 'A');
297+
else if (c >= 'a' && c <= 'z')
298+
v = static_cast<int8_t>(c - 'a' + 26);
299+
else if (c >= '0' && c <= '9')
300+
v = static_cast<int8_t>(c - '0' + 52);
301+
else if (c == '-' || c == '+')
302+
v = 62;
303+
else if (c == '_' || c == '/')
304+
v = 63;
305+
306+
if (v == -1) {
307+
return InvalidArgument("Invalid character in Base64Url string: '{}'", c);
308+
}
309+
310+
val = (val << 6) | static_cast<uint32_t>(v);
311+
bits += 6;
312+
313+
if (bits >= 8) {
314+
bits -= 8;
315+
decoded.push_back(static_cast<char>((val >> bits) & 0xFF));
316+
val &= (1U << bits) - 1;
317+
}
318+
}
319+
return decoded;
320+
}
321+
286322
} // namespace iceberg

src/iceberg/util/transform_util.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ class ICEBERG_EXPORT TransformUtil {
139139

140140
/// \brief Base64 encode a string
141141
static std::string Base64Encode(std::string_view str_to_encode);
142+
143+
/// \brief Base64Url decode a string
144+
static Result<std::string> Base64UrlDecode(std::string_view str_to_decode);
142145
};
143146

144147
} // namespace iceberg

0 commit comments

Comments
 (0)