Skip to content

Commit 44c06f5

Browse files
Copilotbbockelm
andcommitted
Add keycache load, metadata, and delete APIs with tests
Co-authored-by: bbockelm <1093447+bbockelm@users.noreply.github.com>
1 parent 928d97c commit 44c06f5

File tree

5 files changed

+351
-0
lines changed

5 files changed

+351
-0
lines changed

src/scitokens.cpp

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,6 +1134,78 @@ int keycache_stop_background_refresh(char **err_msg) {
11341134
return keycache_set_background_refresh(0, err_msg);
11351135
}
11361136

1137+
int keycache_load_jwks(const char *issuer, char **jwks, char **err_msg) {
1138+
if (!issuer) {
1139+
if (err_msg) {
1140+
*err_msg = strdup("Issuer may not be a null pointer");
1141+
}
1142+
return -1;
1143+
}
1144+
if (!jwks) {
1145+
if (err_msg) {
1146+
*err_msg = strdup("JWKS output pointer may not be null.");
1147+
}
1148+
return -1;
1149+
}
1150+
try {
1151+
*jwks = strdup(scitokens::Validator::load_jwks(issuer).c_str());
1152+
} catch (std::exception &exc) {
1153+
if (err_msg) {
1154+
*err_msg = strdup(exc.what());
1155+
}
1156+
return -1;
1157+
}
1158+
return 0;
1159+
}
1160+
1161+
int keycache_get_jwks_metadata(const char *issuer, char **metadata,
1162+
char **err_msg) {
1163+
if (!issuer) {
1164+
if (err_msg) {
1165+
*err_msg = strdup("Issuer may not be a null pointer");
1166+
}
1167+
return -1;
1168+
}
1169+
if (!metadata) {
1170+
if (err_msg) {
1171+
*err_msg = strdup("Metadata output pointer may not be null.");
1172+
}
1173+
return -1;
1174+
}
1175+
try {
1176+
*metadata = strdup(scitokens::Validator::get_jwks_metadata(issuer).c_str());
1177+
} catch (std::exception &exc) {
1178+
if (err_msg) {
1179+
*err_msg = strdup(exc.what());
1180+
}
1181+
return -1;
1182+
}
1183+
return 0;
1184+
}
1185+
1186+
int keycache_delete_jwks(const char *issuer, char **err_msg) {
1187+
if (!issuer) {
1188+
if (err_msg) {
1189+
*err_msg = strdup("Issuer may not be a null pointer");
1190+
}
1191+
return -1;
1192+
}
1193+
try {
1194+
if (!scitokens::Validator::delete_jwks(issuer)) {
1195+
if (err_msg) {
1196+
*err_msg = strdup("Failed to delete JWKS cache entry for issuer.");
1197+
}
1198+
return -1;
1199+
}
1200+
} catch (std::exception &exc) {
1201+
if (err_msg) {
1202+
*err_msg = strdup(exc.what());
1203+
}
1204+
return -1;
1205+
}
1206+
return 0;
1207+
}
1208+
11371209
int config_set_int(const char *key, int value, char **err_msg) {
11381210
return scitoken_config_set_int(key, value, err_msg);
11391211
}

src/scitokens.h

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,36 @@ int keycache_set_background_refresh(int enabled, char **err_msg);
309309
*/
310310
int keycache_stop_background_refresh(char **err_msg);
311311

312+
/**
313+
* Load the JWKS from the keycache for a given issuer, refreshing only if needed.
314+
* - Returns 0 if successful, nonzero on failure.
315+
* - If the existing JWKS has not expired, this will return the cached JWKS
316+
* without triggering a download.
317+
* - If the JWKS has expired or does not exist, this will attempt to refresh
318+
* it from the issuer.
319+
* - `jwks` is an output variable set to the contents of the JWKS.
320+
*/
321+
int keycache_load_jwks(const char *issuer, char **jwks, char **err_msg);
322+
323+
/**
324+
* Get metadata for a cached JWKS entry.
325+
* - Returns 0 if successful, nonzero on failure.
326+
* - `metadata` is an output variable set to a JSON string containing:
327+
* - "expires": expiration time (Unix epoch seconds)
328+
* - "next_update": next update time (Unix epoch seconds)
329+
* - "extra": additional metadata (currently an empty JSON object)
330+
* - If the issuer does not exist in the cache, returns an error.
331+
*/
332+
int keycache_get_jwks_metadata(const char *issuer, char **metadata,
333+
char **err_msg);
334+
335+
/**
336+
* Delete a JWKS entry from the keycache.
337+
* - Returns 0 if successful, nonzero on failure.
338+
* - If the issuer does not exist in the cache, this is not considered an error.
339+
*/
340+
int keycache_delete_jwks(const char *issuer, char **err_msg);
341+
312342
/**
313343
* APIs for managing scitokens configuration parameters.
314344
*/

src/scitokens_cache.cpp

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,3 +425,133 @@ scitokens::Validator::get_all_issuers_from_db(int64_t now) {
425425
sqlite3_close(db);
426426
return result;
427427
}
428+
429+
std::string scitokens::Validator::load_jwks(const std::string &issuer) {
430+
auto now = std::time(NULL);
431+
picojson::value jwks;
432+
int64_t next_update;
433+
434+
try {
435+
// Try to get from cache
436+
if (get_public_keys_from_db(issuer, now, jwks, next_update)) {
437+
// Check if refresh is needed (expired based on next_update)
438+
if (now <= next_update) {
439+
// Still valid, return cached version
440+
return jwks.serialize();
441+
}
442+
// Past next_update, need to refresh
443+
}
444+
} catch (const NegativeCacheHitException &) {
445+
// Negative cache hit - return empty keys
446+
return std::string("{\"keys\": []}");
447+
}
448+
449+
// Either not in cache or past next_update - refresh
450+
if (!refresh_jwks(issuer)) {
451+
throw CurlException("Failed to load JWKS for issuer: " + issuer);
452+
}
453+
454+
// Get the newly refreshed JWKS
455+
return get_jwks(issuer);
456+
}
457+
458+
std::string scitokens::Validator::get_jwks_metadata(const std::string &issuer) {
459+
auto now = std::time(NULL);
460+
int64_t next_update = -1;
461+
int64_t expires = -1;
462+
463+
// Get the metadata from database without expiry check
464+
auto cache_fname = get_cache_file();
465+
if (cache_fname.size() == 0) {
466+
throw std::runtime_error("Unable to access cache file");
467+
}
468+
469+
sqlite3 *db;
470+
int rc = sqlite3_open(cache_fname.c_str(), &db);
471+
if (rc) {
472+
sqlite3_close(db);
473+
throw std::runtime_error("Failed to open cache database");
474+
}
475+
sqlite3_busy_timeout(db, SQLITE_BUSY_TIMEOUT_MS);
476+
477+
sqlite3_stmt *stmt;
478+
rc = sqlite3_prepare_v2(db, "SELECT keys from keycache where issuer = ?",
479+
-1, &stmt, NULL);
480+
if (rc != SQLITE_OK) {
481+
sqlite3_close(db);
482+
throw std::runtime_error("Failed to prepare database query");
483+
}
484+
485+
if (sqlite3_bind_text(stmt, 1, issuer.c_str(), issuer.size(),
486+
SQLITE_STATIC) != SQLITE_OK) {
487+
sqlite3_finalize(stmt);
488+
sqlite3_close(db);
489+
throw std::runtime_error("Failed to bind issuer to query");
490+
}
491+
492+
rc = sqlite3_step(stmt);
493+
if (rc == SQLITE_ROW) {
494+
const unsigned char *data = sqlite3_column_text(stmt, 0);
495+
std::string metadata(reinterpret_cast<const char *>(data));
496+
sqlite3_finalize(stmt);
497+
sqlite3_close(db);
498+
499+
picojson::value json_obj;
500+
auto err = picojson::parse(json_obj, metadata);
501+
if (!err.empty() || !json_obj.is<picojson::object>()) {
502+
throw JsonException("Invalid JSON in cache entry");
503+
}
504+
505+
auto top_obj = json_obj.get<picojson::object>();
506+
507+
// Extract expires
508+
auto iter = top_obj.find("expires");
509+
if (iter != top_obj.end() && iter->second.is<int64_t>()) {
510+
expires = iter->second.get<int64_t>();
511+
}
512+
513+
// Extract next_update
514+
iter = top_obj.find("next_update");
515+
if (iter != top_obj.end() && iter->second.is<int64_t>()) {
516+
next_update = iter->second.get<int64_t>();
517+
} else if (expires != -1) {
518+
// Default next_update to 4 hours before expiry
519+
next_update = expires - 4 * 3600;
520+
}
521+
522+
// Build metadata JSON
523+
picojson::object metadata_obj;
524+
metadata_obj["expires"] = picojson::value(expires);
525+
metadata_obj["next_update"] = picojson::value(next_update);
526+
metadata_obj["extra"] = picojson::value(picojson::object());
527+
528+
return picojson::value(metadata_obj).serialize();
529+
} else {
530+
sqlite3_finalize(stmt);
531+
sqlite3_close(db);
532+
throw std::runtime_error("Issuer not found in cache");
533+
}
534+
}
535+
536+
bool scitokens::Validator::delete_jwks(const std::string &issuer) {
537+
auto cache_fname = get_cache_file();
538+
if (cache_fname.size() == 0) {
539+
return false;
540+
}
541+
542+
sqlite3 *db;
543+
int rc = sqlite3_open(cache_fname.c_str(), &db);
544+
if (rc) {
545+
sqlite3_close(db);
546+
return false;
547+
}
548+
sqlite3_busy_timeout(db, SQLITE_BUSY_TIMEOUT_MS);
549+
550+
// Use the existing remove_issuer_entry function
551+
if (remove_issuer_entry(db, issuer, true) != 0) {
552+
return false;
553+
}
554+
555+
sqlite3_close(db);
556+
return true;
557+
}

src/scitokens_internal.h

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,6 +1309,25 @@ class Validator {
13091309
static std::vector<std::pair<std::string, int64_t>>
13101310
get_all_issuers_from_db(int64_t now);
13111311

1312+
/**
1313+
* Load JWKS for a given issuer, refreshing only if needed.
1314+
* Returns the JWKS string. If refresh is needed and fails, throws exception.
1315+
*/
1316+
static std::string load_jwks(const std::string &issuer);
1317+
1318+
/**
1319+
* Get metadata for a cached JWKS entry.
1320+
* Returns a JSON string with expires, next_update, and extra fields.
1321+
* Throws exception if issuer not found in cache.
1322+
*/
1323+
static std::string get_jwks_metadata(const std::string &issuer);
1324+
1325+
/**
1326+
* Delete a JWKS entry from the keycache.
1327+
* Returns true on success, false on failure.
1328+
*/
1329+
static bool delete_jwks(const std::string &issuer);
1330+
13121331
private:
13131332
static std::unique_ptr<AsyncStatus>
13141333
get_public_key_pem(const std::string &issuer, const std::string &kid,

test/main.cpp

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -955,6 +955,106 @@ TEST_F(KeycacheTest, NegativeCacheTest) {
955955
<< "Should have 2 negative cache hits. JSON: " << json_str;
956956
}
957957

958+
TEST_F(KeycacheTest, LoadJwksTest) {
959+
// Test load API - should return cached JWKS without triggering refresh
960+
char *err_msg = nullptr;
961+
char *jwks = nullptr;
962+
963+
// Load JWKS - should return cached version from SetUp()
964+
auto rv = keycache_load_jwks(demo_scitokens_url.c_str(), &jwks, &err_msg);
965+
ASSERT_TRUE(rv == 0) << (err_msg ? err_msg : "unknown error");
966+
ASSERT_TRUE(jwks != nullptr);
967+
std::string jwks_str(jwks);
968+
free(jwks);
969+
if (err_msg) free(err_msg);
970+
971+
EXPECT_EQ(demo_scitokens, jwks_str);
972+
}
973+
974+
TEST_F(KeycacheTest, LoadJwksMissingTest) {
975+
// Test load API with missing issuer - should attempt refresh
976+
char *err_msg = nullptr;
977+
char *jwks = nullptr;
978+
979+
// Try to load a non-existent issuer - will fail to refresh
980+
auto rv = keycache_load_jwks("https://demo.scitokens.org/nonexistent", &jwks, &err_msg);
981+
ASSERT_FALSE(rv == 0); // Should fail since issuer doesn't exist
982+
if (err_msg) free(err_msg);
983+
}
984+
985+
TEST_F(KeycacheTest, GetMetadataTest) {
986+
// Test metadata API - should return expires and next_update
987+
char *err_msg = nullptr;
988+
char *metadata = nullptr;
989+
990+
// Get metadata for cached issuer
991+
auto rv = keycache_get_jwks_metadata(demo_scitokens_url.c_str(), &metadata, &err_msg);
992+
ASSERT_TRUE(rv == 0) << (err_msg ? err_msg : "unknown error");
993+
ASSERT_TRUE(metadata != nullptr);
994+
std::string metadata_str(metadata);
995+
free(metadata);
996+
if (err_msg) free(err_msg);
997+
998+
// Verify JSON structure - should have expires, next_update, and extra fields
999+
EXPECT_NE(metadata_str.find("\"expires\":"), std::string::npos);
1000+
EXPECT_NE(metadata_str.find("\"next_update\":"), std::string::npos);
1001+
EXPECT_NE(metadata_str.find("\"extra\":"), std::string::npos);
1002+
}
1003+
1004+
TEST_F(KeycacheTest, GetMetadataMissingTest) {
1005+
// Test metadata API with missing issuer
1006+
char *err_msg = nullptr;
1007+
char *metadata = nullptr;
1008+
1009+
// Try to get metadata for non-existent issuer
1010+
auto rv = keycache_get_jwks_metadata("https://demo.scitokens.org/unknown", &metadata, &err_msg);
1011+
ASSERT_FALSE(rv == 0); // Should fail
1012+
if (err_msg) free(err_msg);
1013+
}
1014+
1015+
TEST_F(KeycacheTest, DeleteJwksTest) {
1016+
// Test delete API
1017+
char *err_msg = nullptr;
1018+
1019+
// First verify the issuer is in cache
1020+
char *jwks = nullptr;
1021+
auto rv = keycache_get_cached_jwks(demo_scitokens_url.c_str(), &jwks, &err_msg);
1022+
ASSERT_TRUE(rv == 0) << (err_msg ? err_msg : "unknown error");
1023+
ASSERT_TRUE(jwks != nullptr);
1024+
free(jwks);
1025+
if (err_msg) {
1026+
free(err_msg);
1027+
err_msg = nullptr;
1028+
}
1029+
1030+
// Delete the entry
1031+
rv = keycache_delete_jwks(demo_scitokens_url.c_str(), &err_msg);
1032+
ASSERT_TRUE(rv == 0) << (err_msg ? err_msg : "unknown error");
1033+
if (err_msg) {
1034+
free(err_msg);
1035+
err_msg = nullptr;
1036+
}
1037+
1038+
// Verify it's gone - get_cached_jwks should return empty keys
1039+
rv = keycache_get_cached_jwks(demo_scitokens_url.c_str(), &jwks, &err_msg);
1040+
ASSERT_TRUE(rv == 0) << (err_msg ? err_msg : "unknown error");
1041+
ASSERT_TRUE(jwks != nullptr);
1042+
std::string jwks_str(jwks);
1043+
free(jwks);
1044+
if (err_msg) free(err_msg);
1045+
1046+
EXPECT_EQ(jwks_str, "{\"keys\": []}");
1047+
}
1048+
1049+
TEST_F(KeycacheTest, DeleteJwksNonExistentTest) {
1050+
// Test delete API with non-existent issuer - should not fail
1051+
char *err_msg = nullptr;
1052+
1053+
auto rv = keycache_delete_jwks("https://demo.scitokens.org/never-existed", &err_msg);
1054+
ASSERT_TRUE(rv == 0) << (err_msg ? err_msg : "unknown error"); // Should succeed (idempotent)
1055+
if (err_msg) free(err_msg);
1056+
}
1057+
9581058
class IssuerSecurityTest : public ::testing::Test {
9591059
protected:
9601060
void SetUp() override {

0 commit comments

Comments
 (0)