diff --git a/README.md b/README.md index 7f0d093..4745cdf 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,15 @@ echo "" | ./scitokens-verify Replace the given token above with the fresh one you just generated; using the above token should give an expired token error. The token must be provided via standard input (stdin). +Monitoring +---------- + +The library can emit per-issuer monitoring statistics in JSON via `scitoken_get_monitoring_json()` (C API). Common +fields include `successful_validations`, `unsuccessful_validations`, `successful_key_lookups`, and the filesystem +cache counters `system_cache_hits` and `system_cache_expired` that report JWKS lookups satisfied by system cache files +and expired cache entries that required a web fallback. Monitoring file output can be enabled with the +`monitoring.file` and `monitoring.file_interval_s` configuration options. + Generating Keys for Testing ---------------------------- diff --git a/src/scitokens_cache.cpp b/src/scitokens_cache.cpp index 3dd0a8c..b9aff9d 100644 --- a/src/scitokens_cache.cpp +++ b/src/scitokens_cache.cpp @@ -1,7 +1,13 @@ +#include #include +#include +#include +#include #include +#include #include +#include #include #include @@ -14,6 +20,9 @@ #include #include +#include +#include + #include "scitokens_internal.h" namespace { @@ -25,6 +34,290 @@ constexpr int SQLITE_BUSY_TIMEOUT_MS = 5000; // Default time before expiry when next_update should occur (4 hours) constexpr int64_t DEFAULT_NEXT_UPDATE_OFFSET_S = 4 * 3600; +enum class CacheLookupResult { Found, NotFound, ParseError, Expired }; + +bool json_to_int64(const picojson::value &val, int64_t &out) { + if (val.is()) { + out = val.get(); + return true; + } + if (val.is()) { + out = static_cast(std::floor(val.get())); + return true; + } + return false; +} + +std::string issuer_hash_prefix(const std::string &issuer) { + unsigned char hash[SHA256_DIGEST_LENGTH]; + SHA256(reinterpret_cast(issuer.data()), + issuer.size(), hash); + + std::ostringstream oss; + oss << std::hex << std::setfill('0'); + for (int i = 0; i < 4; i++) { + oss << std::setw(2) << static_cast(hash[i]); + } + return oss.str(); +} + +bool extract_json_objects(const std::string &content, + std::vector &objects) { + size_t idx = 0; + const size_t len = content.size(); + + while (idx < len) { + while (idx < len && isspace(static_cast(content[idx]))) { + idx++; + } + if (idx >= len) { + break; + } + if (content[idx] != '{') { + break; // Non-whitespace outside JSON terminates parsing + } + + size_t start = idx; + int depth = 0; + bool in_string = false; + bool escape = false; + for (; idx < len; idx++) { + char c = content[idx]; + if (in_string) { + if (escape) { + escape = false; + continue; + } + if (c == '\\') { + escape = true; + } else if (c == '"') { + in_string = false; + } + } else { + if (c == '"') { + in_string = true; + } else if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + if (depth == 0) { + objects.emplace_back( + content.substr(start, idx - start + 1)); + idx++; + break; + } + } + } + } + + if (depth != 0) { + return false; // Unbalanced braces + } + } + return true; +} + +CacheLookupResult parse_cache_object_for_issuer(const picojson::value &val, + const std::string &issuer, + int64_t now, + picojson::value &keys, + int64_t &next_update) { + if (!val.is()) { + return CacheLookupResult::ParseError; + } + + const auto &root = val.get(); + auto issuer_it = root.find(issuer); + if (issuer_it == root.end()) { + return CacheLookupResult::NotFound; + } + + if (!issuer_it->second.is()) { + return CacheLookupResult::ParseError; + } + + const auto &entry_obj = issuer_it->second.get(); + + auto exp_it = entry_obj.find("expiration"); + if (exp_it == entry_obj.end()) { + return CacheLookupResult::ParseError; + } + + int64_t expiration; + if (!json_to_int64(exp_it->second, expiration)) { + return CacheLookupResult::ParseError; + } + + auto jwks_it = entry_obj.find("jwks"); + if (jwks_it == entry_obj.end() || !jwks_it->second.is()) { + return CacheLookupResult::ParseError; + } + + if (now > expiration) { + return CacheLookupResult::Expired; + } + + int64_t nu_value = expiration - DEFAULT_NEXT_UPDATE_OFFSET_S; + auto nu_it = entry_obj.find("next_update"); + if (nu_it != entry_obj.end()) { + int64_t tmp; + if (!json_to_int64(nu_it->second, tmp)) { + return CacheLookupResult::ParseError; + } + nu_value = tmp; + } + + keys = jwks_it->second; + next_update = nu_value; + return CacheLookupResult::Found; +} + +CacheLookupResult parse_cache_file_for_issuer(const std::string &path, + const std::string &issuer, + int64_t now, + picojson::value &keys, + int64_t &next_update) { + std::ifstream infile(path); + if (!infile) { + return CacheLookupResult::NotFound; + } + + std::stringstream buffer; + buffer << infile.rdbuf(); + std::string content = buffer.str(); + + std::vector objects; + if (!extract_json_objects(content, objects)) { + return CacheLookupResult::ParseError; + } + + CacheLookupResult status = CacheLookupResult::NotFound; + for (const auto &obj_str : objects) { + picojson::value val; + std::string err = picojson::parse(val, obj_str); + if (!err.empty()) { + return CacheLookupResult::ParseError; + } + + auto res = + parse_cache_object_for_issuer(val, issuer, now, keys, next_update); + if (res == CacheLookupResult::ParseError) { + return res; + } + if (res == CacheLookupResult::Found) { + status = CacheLookupResult::Found; + } else if (res == CacheLookupResult::Expired && + status == CacheLookupResult::NotFound) { + status = CacheLookupResult::Expired; + } + } + + return status; +} + +CacheLookupResult parse_cache_dir_for_issuer(const std::string &dir, + const std::string &issuer, + int64_t now, picojson::value &keys, + int64_t &next_update) { + bool expired_seen = false; + auto prefix = issuer_hash_prefix(issuer); + for (int idx = 0;; idx++) { + std::ostringstream fname; + fname << dir << "/" << prefix << "." << idx; + + struct stat st; + if (stat(fname.str().c_str(), &st) != 0) { + break; // First missing file terminates search + } + if (!S_ISREG(st.st_mode)) { + continue; + } + + auto res = parse_cache_file_for_issuer(fname.str(), issuer, now, keys, + next_update); + if (res == CacheLookupResult::Found || + res == CacheLookupResult::ParseError) { + return res; + } + if (res == CacheLookupResult::Expired) { + expired_seen = true; + } + } + + if (expired_seen) { + return CacheLookupResult::Expired; + } + return CacheLookupResult::NotFound; +} + +bool get_public_keys_from_system_cache(const std::string &issuer, int64_t now, + picojson::value &keys, + int64_t &next_update, + bool &expired_hit) { + + expired_hit = false; + + struct Location { + enum class Type { Dir, File } type; + std::string path; + }; + + std::vector search_order; + const char *env_dir = getenv("JWKS_CACHE_DIR"); + const char *env_file = getenv("JWKS_CACHE_FILE"); + + if (env_dir && strlen(env_dir) > 0) { + search_order.push_back({Location::Type::Dir, env_dir}); + } + if (env_file && strlen(env_file) > 0) { + search_order.push_back({Location::Type::File, env_file}); + } + + search_order.push_back({Location::Type::Dir, "/etc/jwks"}); + search_order.push_back({Location::Type::File, "/etc/jwks/cache.json"}); + search_order.push_back({Location::Type::Dir, "/var/cache/jwks"}); + search_order.push_back( + {Location::Type::File, "/var/cache/jwks/cache.json"}); + + for (const auto &loc : search_order) { + struct stat st; + if (stat(loc.path.c_str(), &st) != 0) { + continue; + } + + CacheLookupResult res = CacheLookupResult::NotFound; + if (loc.type == Location::Type::Dir) { + if (!S_ISDIR(st.st_mode)) { + continue; + } + res = parse_cache_dir_for_issuer(loc.path, issuer, now, keys, + next_update); + } else { + if (!S_ISREG(st.st_mode)) { + continue; + } + res = parse_cache_file_for_issuer(loc.path, issuer, now, keys, + next_update); + } + + if (res == CacheLookupResult::Found) { + auto &stats = scitokens::internal::MonitoringStats::instance() + .get_issuer_stats(issuer); + stats.inc_system_cache_hit(); + return true; + } + if (res == CacheLookupResult::ParseError) { + throw scitokens::JsonException("Failed to parse JWKS cache at " + + loc.path); + } + if (res == CacheLookupResult::Expired) { + expired_hit = true; + } + } + + return false; +} + void initialize_cachedb(const std::string &keycache_file) { sqlite3 *db; @@ -159,6 +452,23 @@ bool scitokens::Validator::get_public_keys_from_db(const std::string issuer, int64_t now, picojson::value &keys, int64_t &next_update) { + bool expired_hit = false; + try { + if (get_public_keys_from_system_cache(issuer, now, keys, next_update, + expired_hit)) { + return true; + } + } catch (const JsonException &) { + throw; + } + + if (expired_hit) { + auto &stats = + scitokens::internal::MonitoringStats::instance().get_issuer_stats( + issuer); + stats.inc_system_cache_expired(); + } + auto cache_fname = get_cache_file(); if (cache_fname.size() == 0) { return false; diff --git a/src/scitokens_internal.h b/src/scitokens_internal.h index b54d46d..9ba23da 100644 --- a/src/scitokens_internal.h +++ b/src/scitokens_internal.h @@ -260,6 +260,10 @@ struct IssuerStats { std::atomic failed_refreshes{0}; std::atomic stale_key_uses{0}; + // System cache statistics + std::atomic system_cache_hits{0}; + std::atomic system_cache_expired{0}; + // Background refresh statistics (tracked by background thread) std::atomic background_successful_refreshes{0}; std::atomic background_failed_refreshes{0}; @@ -307,6 +311,12 @@ struct IssuerStats { void inc_negative_cache_hit() { negative_cache_hits.fetch_add(1, std::memory_order_relaxed); } + void inc_system_cache_hit() { + system_cache_hits.fetch_add(1, std::memory_order_relaxed); + } + void inc_system_cache_expired() { + system_cache_expired.fetch_add(1, std::memory_order_relaxed); + } // Time setters that accept std::chrono::duration (use relaxed ordering) template @@ -362,6 +372,14 @@ struct IssuerStats { failed_key_lookup_time_ns.load(std::memory_order_relaxed)) / 1e9; } + + uint64_t get_system_cache_hits() const { + return system_cache_hits.load(std::memory_order_relaxed); + } + + uint64_t get_system_cache_expired() const { + return system_cache_expired.load(std::memory_order_relaxed); + } }; /** diff --git a/src/scitokens_monitoring.cpp b/src/scitokens_monitoring.cpp index 39889a9..28ae6bb 100644 --- a/src/scitokens_monitoring.cpp +++ b/src/scitokens_monitoring.cpp @@ -139,6 +139,13 @@ std::string MonitoringStats::get_json() const { picojson::value(static_cast( stats.negative_cache_hits.load(std::memory_order_relaxed))); + // System cache statistics + issuer_obj["system_cache_hits"] = picojson::value(static_cast( + stats.system_cache_hits.load(std::memory_order_relaxed))); + issuer_obj["system_cache_expired"] = + picojson::value(static_cast( + stats.system_cache_expired.load(std::memory_order_relaxed))); + std::string sanitized_issuer = sanitize_issuer_for_json(issuer); issuers_obj[sanitized_issuer] = picojson::value(issuer_obj); } diff --git a/test/integration_test.cpp b/test/integration_test.cpp index 6ec5a95..ca6cbdf 100644 --- a/test/integration_test.cpp +++ b/test/integration_test.cpp @@ -4,12 +4,15 @@ #include #include #include +#include #include #include +#include #include #include #include #include +#include #include #include #include @@ -19,6 +22,7 @@ #ifndef PICOJSON_USE_INT64 #define PICOJSON_USE_INT64 #endif +#include #include using scitokens_test::SecureTempDir; @@ -50,6 +54,9 @@ class MonitoringStats { // Background refresh statistics uint64_t background_successful_refreshes{0}; uint64_t background_failed_refreshes{0}; + // System cache statistics + uint64_t system_cache_hits{0}; + uint64_t system_cache_expired{0}; }; struct FailedIssuerLookup { @@ -181,6 +188,19 @@ class MonitoringStats { static_cast(it->second.get()); } + // System cache statistics + it = stats_obj.find("system_cache_hits"); + if (it != stats_obj.end() && it->second.is()) { + stats.system_cache_hits = + static_cast(it->second.get()); + } + + it = stats_obj.find("system_cache_expired"); + if (it != stats_obj.end() && it->second.is()) { + stats.system_cache_expired = + static_cast(it->second.get()); + } + issuers_[issuer_entry.first] = stats; } } @@ -327,6 +347,54 @@ class TestEnvironment { std::map vars_; }; +// Load JWKS from the integration test fixture +picojson::value loadFixtureJwks() { + auto jwks_path = TestEnvironment::getInstance().get("JWKS_FILE"); + std::ifstream jwks_ifs(jwks_path); + if (!jwks_ifs.is_open()) { + throw std::runtime_error("Failed to open JWKS fixture file"); + } + + std::string jwks_str((std::istreambuf_iterator(jwks_ifs)), + std::istreambuf_iterator()); + picojson::value jwks_val; + std::string err = picojson::parse(jwks_val, jwks_str); + if (!err.empty()) { + throw std::runtime_error("Failed to parse JWKS fixture: " + err); + } + return jwks_val; +} + +// Compute the system cache filename prefix (first 4 bytes of SHA256) +std::string issuerHashPrefix(const std::string &issuer) { + unsigned char hash[SHA256_DIGEST_LENGTH]; + SHA256(reinterpret_cast(issuer.data()), + issuer.size(), hash); + + std::ostringstream oss; + oss << std::hex << std::setfill('0'); + for (int i = 0; i < 4; i++) { + oss << std::setw(2) << static_cast(hash[i]); + } + return oss.str(); +} + +// Write a system cache file entry for the issuer +void writeSystemCacheFile(const std::string &path, const std::string &issuer, + const picojson::value &jwks, int64_t expiration, + int64_t next_update) { + picojson::object entry; + entry["jwks"] = jwks; + entry["expiration"] = picojson::value(static_cast(expiration)); + entry["next_update"] = picojson::value(static_cast(next_update)); + + picojson::object root; + root[issuer] = picojson::value(entry); + + std::ofstream ofs(path); + ofs << picojson::value(root).serialize(); +} + class IntegrationTest : public ::testing::Test { protected: void SetUp() override { @@ -1038,6 +1106,194 @@ TEST_F(IntegrationTest, MonitoringDurationTracking) { << issuer_stats.total_validation_time_s << std::endl; } +TEST_F(IntegrationTest, SystemCacheFileHitAndCounters) { + char *err_msg = nullptr; + + scitoken_reset_monitoring_stats(&err_msg); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + SecureTempDir cache_home("system_cache_file_home_"); + ASSERT_TRUE(cache_home.valid()); + int rv = scitoken_config_set_str("keycache.cache_home", + cache_home.path().c_str(), &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + SecureTempDir system_cache_dir("system_cache_file_"); + ASSERT_TRUE(system_cache_dir.valid()); + std::string cache_file = system_cache_dir.path() + "/cache.json"; + + auto jwks_val = loadFixtureJwks(); + auto now = std::time(nullptr); + writeSystemCacheFile(cache_file, issuer_url_, jwks_val, now + 3600, + now + 1800); + + setenv("JWKS_CACHE_FILE", cache_file.c_str(), 1); + unsetenv("JWKS_CACHE_DIR"); + + std::unique_ptr key( + scitoken_key_create("test-key-1", "ES256", public_key_.c_str(), + private_key_.c_str(), &err_msg), + scitoken_key_destroy); + ASSERT_TRUE(key.get() != nullptr); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + std::unique_ptr token( + scitoken_create(key.get()), scitoken_destroy); + ASSERT_TRUE(token.get() != nullptr); + + rv = scitoken_set_claim_string(token.get(), "iss", issuer_url_.c_str(), + &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + scitoken_set_lifetime(token.get(), 600); + + char *token_value = nullptr; + rv = scitoken_serialize(token.get(), &token_value, &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + std::unique_ptr token_value_ptr(token_value, free); + + std::unique_ptr verify_token( + scitoken_create(nullptr), scitoken_destroy); + ASSERT_TRUE(verify_token.get() != nullptr); + + rv = scitoken_deserialize_v2(token_value, verify_token.get(), nullptr, + &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + auto stats = getCurrentMonitoringStats(); + auto issuer_stats = stats.getIssuerStats(issuer_url_); + + EXPECT_GT(issuer_stats.system_cache_hits, 0u) + << "System cache file should be used before web fetch"; + EXPECT_EQ(issuer_stats.system_cache_expired, 0u) + << "System cache should not be marked expired"; + + unsetenv("JWKS_CACHE_FILE"); + unsetenv("JWKS_CACHE_DIR"); + scitoken_config_set_str("keycache.cache_home", "", &err_msg); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } +} + +TEST_F(IntegrationTest, SystemCacheDirExpiredFallsBackToWeb) { + char *err_msg = nullptr; + + scitoken_reset_monitoring_stats(&err_msg); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + SecureTempDir cache_home("system_cache_dir_home_"); + ASSERT_TRUE(cache_home.valid()); + int rv = scitoken_config_set_str("keycache.cache_home", + cache_home.path().c_str(), &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + SecureTempDir system_cache_dir("system_cache_dir_"); + ASSERT_TRUE(system_cache_dir.valid()); + auto prefix = issuerHashPrefix(issuer_url_); + std::string cache_file = system_cache_dir.path() + "/" + prefix + ".0"; + + auto jwks_val = loadFixtureJwks(); + auto now = std::time(nullptr); + writeSystemCacheFile(cache_file, issuer_url_, jwks_val, now - 10, now - 20); + + setenv("JWKS_CACHE_DIR", system_cache_dir.path().c_str(), 1); + unsetenv("JWKS_CACHE_FILE"); + + std::unique_ptr key( + scitoken_key_create("test-key-1", "ES256", public_key_.c_str(), + private_key_.c_str(), &err_msg), + scitoken_key_destroy); + ASSERT_TRUE(key.get() != nullptr); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + std::unique_ptr token( + scitoken_create(key.get()), scitoken_destroy); + ASSERT_TRUE(token.get() != nullptr); + + rv = scitoken_set_claim_string(token.get(), "iss", issuer_url_.c_str(), + &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + scitoken_set_lifetime(token.get(), 600); + + char *token_value = nullptr; + rv = scitoken_serialize(token.get(), &token_value, &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + std::unique_ptr token_value_ptr(token_value, free); + + std::unique_ptr verify_token( + scitoken_create(nullptr), scitoken_destroy); + ASSERT_TRUE(verify_token.get() != nullptr); + + rv = scitoken_deserialize_v2(token_value, verify_token.get(), nullptr, + &err_msg); + ASSERT_EQ(rv, 0); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } + + auto stats = getCurrentMonitoringStats(); + auto issuer_stats = stats.getIssuerStats(issuer_url_); + + EXPECT_EQ(issuer_stats.system_cache_hits, 0u) + << "Expired system cache entry should not count as hit"; + EXPECT_GT(issuer_stats.system_cache_expired, 0u) + << "Expired system cache entry should be recorded"; + EXPECT_GT(issuer_stats.successful_key_lookups, 0u) + << "Key lookup should have succeeded via web fallback"; + + unsetenv("JWKS_CACHE_DIR"); + unsetenv("JWKS_CACHE_FILE"); + scitoken_config_set_str("keycache.cache_home", "", &err_msg); + if (err_msg) { + free(err_msg); + err_msg = nullptr; + } +} + // Test monitoring file output during token verification TEST_F(IntegrationTest, MonitoringFileOutput) { char *err_msg = nullptr; diff --git a/test/main.cpp b/test/main.cpp index 3c7238a..b62d69d 100644 --- a/test/main.cpp +++ b/test/main.cpp @@ -4,8 +4,13 @@ #include #include #include +#include +#include #include +#include #include +#include +#include #ifndef PICOJSON_USE_INT64 #define PICOJSON_USE_INT64 #endif @@ -678,6 +683,131 @@ class KeycacheTest : public ::testing::Test { "\",\"y\":\"sCsFXvx7FAAklwq3CzRCBcghqZOFPB2dKUayS6LY_Lo=\"}]}"; }; +namespace { + +class EnvGuard { + public: + EnvGuard(const std::string &name, const std::string &value) : m_name(name) { + const char *prev = std::getenv(name.c_str()); + if (prev) { + m_prev = prev; + m_had_prev = true; + } + setenv(name.c_str(), value.c_str(), 1); + } + + ~EnvGuard() { + if (m_had_prev) { + setenv(m_name.c_str(), m_prev.c_str(), 1); + } else { + unsetenv(m_name.c_str()); + } + } + + private: + std::string m_name; + std::string m_prev; + bool m_had_prev{false}; +}; + +class ConfigStringGuard { + public: + ConfigStringGuard(const std::string &key, const std::string &value) + : m_key(key) { + char *prev = nullptr; + char *err = nullptr; + if (scitoken_config_get_str(key.c_str(), &prev, &err) == 0 && prev) { + m_prev = prev; + m_had_prev = true; + free(prev); + } + if (err) { + free(err); + } + + char *err2 = nullptr; + scitoken_config_set_str(key.c_str(), value.c_str(), &err2); + if (err2) { + free(err2); + } + } + + ~ConfigStringGuard() { + char *err = nullptr; + if (m_had_prev) { + scitoken_config_set_str(m_key.c_str(), m_prev.c_str(), &err); + } else { + scitoken_config_set_str(m_key.c_str(), "", &err); + } + if (err) { + free(err); + } + } + + private: + std::string m_key; + std::string m_prev; + bool m_had_prev{false}; +}; + +std::string hash_prefix(const std::string &issuer) { + unsigned char hash[SHA256_DIGEST_LENGTH]; + SHA256(reinterpret_cast(issuer.data()), + issuer.size(), hash); + + std::ostringstream oss; + oss << std::hex << std::setfill('0'); + for (int i = 0; i < 4; i++) { + oss << std::setw(2) << static_cast(hash[i]); + } + return oss.str(); +} + +std::string make_cache_object(const std::string &issuer, + const std::string &jwks, int64_t expiration, + int64_t next_update = -1) { + std::ostringstream ss; + ss << "{\n \"" << issuer << "\": {\n \"expiration\": " << expiration; + if (next_update >= 0) { + ss << ",\n \"next_update\": " << next_update; + } + ss << ",\n \"jwks\": " << jwks << "\n }\n}"; + return ss.str(); +} + +std::string get_first_kid(const std::string &jwks) { + picojson::value root; + std::string err = picojson::parse(root, jwks); + if (!err.empty()) { + return ""; + } + if (!root.is()) { + return ""; + } + auto &obj = root.get(); + auto keys_it = obj.find("keys"); + if (keys_it == obj.end() || !keys_it->second.is()) { + return ""; + } + auto &arr = keys_it->second.get(); + if (arr.empty() || !arr[0].is()) { + return ""; + } + auto kid_it = arr[0].get().find("kid"); + if (kid_it == arr[0].get().end() || + !kid_it->second.is()) { + return ""; + } + return kid_it->second.get(); +} + +void write_file(const std::string &path, const std::string &content) { + std::ofstream ofs(path); + ofs << content; +} + +} // namespace + TEST_F(KeycacheTest, RefreshTest) { char *err_msg = nullptr; auto rv = keycache_refresh_jwks(demo_scitokens_url.c_str(), &err_msg); @@ -975,6 +1105,131 @@ TEST_F(KeycacheTest, NegativeCacheTest) { << "Should have 2 negative cache hits. JSON: " << json_str; } +TEST_F(KeycacheTest, DirectoryCacheLookup) { + SecureTempDir cache_dir("jwks_cache_dir_"); + ASSERT_TRUE(cache_dir.valid()); + + std::string issuer = "https://directory.example"; + auto prefix = hash_prefix(issuer); + std::string cache_path = cache_dir.path() + "/" + prefix + ".0"; + + auto now = static_cast(time(nullptr)); + std::string jwks_payload = "{\"keys\":[{\"kid\":\"dir\",\"kty\":\"RSA\"}]}"; + auto cache_obj = + make_cache_object(issuer, jwks_payload, now + 3600, now + 100); + write_file(cache_path, cache_obj); + + EnvGuard env_dir("JWKS_CACHE_DIR", cache_dir.path()); + ConfigStringGuard cache_home_guard("keycache.cache_home", + cache_dir.path() + "/sqlite_home"); + + char *err_msg = nullptr; + char *jwks = nullptr; + auto rv = keycache_get_cached_jwks(issuer.c_str(), &jwks, &err_msg); + ASSERT_TRUE(rv == 0) << err_msg; + ASSERT_TRUE(jwks != nullptr); + std::string jwks_str(jwks); + free(jwks); + if (err_msg) + free(err_msg); + + EXPECT_EQ(get_first_kid(jwks_str), "dir"); +} + +TEST_F(KeycacheTest, CacheFileMergesObjects) { + SecureTempDir cache_dir("jwks_cache_file_"); + ASSERT_TRUE(cache_dir.valid()); + std::string cache_file = cache_dir.path() + "/cache.json"; + + std::string issuer = "https://file.example"; + auto now = static_cast(time(nullptr)); + std::string jwks_one = "{\"keys\":[{\"kid\":\"one\",\"kty\":\"EC\"}]}"; + std::string jwks_two = "{\"keys\":[{\"kid\":\"two\",\"kty\":\"EC\"}]}"; + + auto obj_one = make_cache_object(issuer, jwks_one, now + 1000, now + 10); + auto obj_two = make_cache_object(issuer, jwks_two, now + 2000, now + 20); + write_file(cache_file, obj_one + "\n" + obj_two); + + EnvGuard env_file("JWKS_CACHE_FILE", cache_file); + ConfigStringGuard cache_home_guard("keycache.cache_home", + cache_dir.path() + "/sqlite_home"); + + char *err_msg = nullptr; + char *jwks = nullptr; + auto rv = keycache_get_cached_jwks(issuer.c_str(), &jwks, &err_msg); + ASSERT_TRUE(rv == 0) << err_msg; + ASSERT_TRUE(jwks != nullptr); + std::string jwks_str(jwks); + free(jwks); + if (err_msg) + free(err_msg); + + EXPECT_EQ(get_first_kid(jwks_str), "two"); +} + +TEST_F(KeycacheTest, SystemCacheBeatsDatabase) { + SecureTempDir cache_dir("jwks_cache_priority_"); + ASSERT_TRUE(cache_dir.valid()); + std::string cache_file = cache_dir.path() + "/cache.json"; + + std::string issuer = "https://priority.example"; + auto now = static_cast(time(nullptr)); + std::string db_jwks = "{\"keys\":[{\"kid\":\"db\",\"kty\":\"RSA\"}]}"; + std::string sys_jwks = "{\"keys\":[{\"kid\":\"sys\",\"kty\":\"RSA\"}]}"; + + ConfigStringGuard cache_home_guard("keycache.cache_home", + cache_dir.path() + "/sqlite_home"); + + char *err_msg = nullptr; + auto rv = keycache_set_jwks(issuer.c_str(), db_jwks.c_str(), &err_msg); + ASSERT_TRUE(rv == 0) << err_msg; + if (err_msg) + free(err_msg); + + auto obj = make_cache_object(issuer, sys_jwks, now + 3600, now + 100); + write_file(cache_file, obj); + EnvGuard env_file("JWKS_CACHE_FILE", cache_file); + + char *jwks = nullptr; + rv = keycache_get_cached_jwks(issuer.c_str(), &jwks, &err_msg); + ASSERT_TRUE(rv == 0) << err_msg; + ASSERT_TRUE(jwks != nullptr); + std::string jwks_str(jwks); + free(jwks); + if (err_msg) + free(err_msg); + + EXPECT_EQ(get_first_kid(jwks_str), "sys"); +} + +TEST_F(KeycacheTest, ExpiredSystemCacheIgnored) { + SecureTempDir cache_dir("jwks_cache_expired_"); + ASSERT_TRUE(cache_dir.valid()); + std::string cache_file = cache_dir.path() + "/cache.json"; + + std::string issuer = "https://expired.example"; + auto now = static_cast(time(nullptr)); + std::string jwks_payload = "{\"keys\":[{\"kid\":\"old\",\"kty\":\"RSA\"}]}"; + auto obj = make_cache_object(issuer, jwks_payload, now - 10, now - 20); + write_file(cache_file, obj); + + EnvGuard env_file("JWKS_CACHE_FILE", cache_file); + ConfigStringGuard cache_home_guard("keycache.cache_home", + cache_dir.path() + "/sqlite_home"); + + char *err_msg = nullptr; + char *jwks = nullptr; + auto rv = keycache_get_cached_jwks(issuer.c_str(), &jwks, &err_msg); + ASSERT_TRUE(rv == 0) << err_msg; + ASSERT_TRUE(jwks != nullptr); + std::string jwks_str(jwks); + free(jwks); + if (err_msg) + free(err_msg); + + EXPECT_EQ(get_first_kid(jwks_str), ""); +} + TEST_F(KeycacheTest, LoadJwksTest) { // Test load API - should return cached JWKS without triggering refresh char *err_msg = nullptr;