Skip to content

Commit ae345dd

Browse files
committed
Add prototype version of the system JWKS cache
1 parent 2b6eba6 commit ae345dd

File tree

6 files changed

+855
-0
lines changed

6 files changed

+855
-0
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ echo "<your_token_here>" | ./scitokens-verify
4242
Replace the given token above with the fresh one you just generated; using the above token should give an expired
4343
token error. The token must be provided via standard input (stdin).
4444

45+
Monitoring
46+
----------
47+
48+
The library can emit per-issuer monitoring statistics in JSON via `scitoken_get_monitoring_json()` (C API). Common
49+
fields include `successful_validations`, `unsuccessful_validations`, `successful_key_lookups`, and the filesystem
50+
cache counters `system_cache_hits` and `system_cache_expired` that report JWKS lookups satisfied by system cache files
51+
and expired cache entries that required a web fallback. Monitoring file output can be enabled with the
52+
`monitoring.file` and `monitoring.file_interval_s` configuration options.
53+
4554
Generating Keys for Testing
4655
----------------------------
4756

src/scitokens_cache.cpp

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11

2+
#include <cctype>
23
#include <cstdint>
4+
#include <cstring>
5+
#include <fstream>
6+
#include <iomanip>
37
#include <memory>
8+
#include <sstream>
49
#include <string>
10+
#include <vector>
511

612
#include <pwd.h>
713
#include <stdlib.h>
@@ -14,6 +20,9 @@
1420
#include <picojson/picojson.h>
1521
#include <sqlite3.h>
1622

23+
#include <cmath>
24+
#include <openssl/sha.h>
25+
1726
#include "scitokens_internal.h"
1827

1928
namespace {
@@ -25,6 +34,290 @@ constexpr int SQLITE_BUSY_TIMEOUT_MS = 5000;
2534
// Default time before expiry when next_update should occur (4 hours)
2635
constexpr int64_t DEFAULT_NEXT_UPDATE_OFFSET_S = 4 * 3600;
2736

37+
enum class CacheLookupResult { Found, NotFound, ParseError, Expired };
38+
39+
bool json_to_int64(const picojson::value &val, int64_t &out) {
40+
if (val.is<int64_t>()) {
41+
out = val.get<int64_t>();
42+
return true;
43+
}
44+
if (val.is<double>()) {
45+
out = static_cast<int64_t>(std::floor(val.get<double>()));
46+
return true;
47+
}
48+
return false;
49+
}
50+
51+
std::string issuer_hash_prefix(const std::string &issuer) {
52+
unsigned char hash[SHA256_DIGEST_LENGTH];
53+
SHA256(reinterpret_cast<const unsigned char *>(issuer.data()),
54+
issuer.size(), hash);
55+
56+
std::ostringstream oss;
57+
oss << std::hex << std::setfill('0');
58+
for (int i = 0; i < 4; i++) {
59+
oss << std::setw(2) << static_cast<int>(hash[i]);
60+
}
61+
return oss.str();
62+
}
63+
64+
bool extract_json_objects(const std::string &content,
65+
std::vector<std::string> &objects) {
66+
size_t idx = 0;
67+
const size_t len = content.size();
68+
69+
while (idx < len) {
70+
while (idx < len && isspace(static_cast<unsigned char>(content[idx]))) {
71+
idx++;
72+
}
73+
if (idx >= len) {
74+
break;
75+
}
76+
if (content[idx] != '{') {
77+
break; // Non-whitespace outside JSON terminates parsing
78+
}
79+
80+
size_t start = idx;
81+
int depth = 0;
82+
bool in_string = false;
83+
bool escape = false;
84+
for (; idx < len; idx++) {
85+
char c = content[idx];
86+
if (in_string) {
87+
if (escape) {
88+
escape = false;
89+
continue;
90+
}
91+
if (c == '\\') {
92+
escape = true;
93+
} else if (c == '"') {
94+
in_string = false;
95+
}
96+
} else {
97+
if (c == '"') {
98+
in_string = true;
99+
} else if (c == '{') {
100+
depth++;
101+
} else if (c == '}') {
102+
depth--;
103+
if (depth == 0) {
104+
objects.emplace_back(
105+
content.substr(start, idx - start + 1));
106+
idx++;
107+
break;
108+
}
109+
}
110+
}
111+
}
112+
113+
if (depth != 0) {
114+
return false; // Unbalanced braces
115+
}
116+
}
117+
return true;
118+
}
119+
120+
CacheLookupResult parse_cache_object_for_issuer(const picojson::value &val,
121+
const std::string &issuer,
122+
int64_t now,
123+
picojson::value &keys,
124+
int64_t &next_update) {
125+
if (!val.is<picojson::object>()) {
126+
return CacheLookupResult::ParseError;
127+
}
128+
129+
const auto &root = val.get<picojson::object>();
130+
auto issuer_it = root.find(issuer);
131+
if (issuer_it == root.end()) {
132+
return CacheLookupResult::NotFound;
133+
}
134+
135+
if (!issuer_it->second.is<picojson::object>()) {
136+
return CacheLookupResult::ParseError;
137+
}
138+
139+
const auto &entry_obj = issuer_it->second.get<picojson::object>();
140+
141+
auto exp_it = entry_obj.find("expiration");
142+
if (exp_it == entry_obj.end()) {
143+
return CacheLookupResult::ParseError;
144+
}
145+
146+
int64_t expiration;
147+
if (!json_to_int64(exp_it->second, expiration)) {
148+
return CacheLookupResult::ParseError;
149+
}
150+
151+
auto jwks_it = entry_obj.find("jwks");
152+
if (jwks_it == entry_obj.end() || !jwks_it->second.is<picojson::object>()) {
153+
return CacheLookupResult::ParseError;
154+
}
155+
156+
if (now > expiration) {
157+
return CacheLookupResult::Expired;
158+
}
159+
160+
int64_t nu_value = expiration - DEFAULT_NEXT_UPDATE_OFFSET_S;
161+
auto nu_it = entry_obj.find("next_update");
162+
if (nu_it != entry_obj.end()) {
163+
int64_t tmp;
164+
if (!json_to_int64(nu_it->second, tmp)) {
165+
return CacheLookupResult::ParseError;
166+
}
167+
nu_value = tmp;
168+
}
169+
170+
keys = jwks_it->second;
171+
next_update = nu_value;
172+
return CacheLookupResult::Found;
173+
}
174+
175+
CacheLookupResult parse_cache_file_for_issuer(const std::string &path,
176+
const std::string &issuer,
177+
int64_t now,
178+
picojson::value &keys,
179+
int64_t &next_update) {
180+
std::ifstream infile(path);
181+
if (!infile) {
182+
return CacheLookupResult::NotFound;
183+
}
184+
185+
std::stringstream buffer;
186+
buffer << infile.rdbuf();
187+
std::string content = buffer.str();
188+
189+
std::vector<std::string> objects;
190+
if (!extract_json_objects(content, objects)) {
191+
return CacheLookupResult::ParseError;
192+
}
193+
194+
CacheLookupResult status = CacheLookupResult::NotFound;
195+
for (const auto &obj_str : objects) {
196+
picojson::value val;
197+
std::string err = picojson::parse(val, obj_str);
198+
if (!err.empty()) {
199+
return CacheLookupResult::ParseError;
200+
}
201+
202+
auto res =
203+
parse_cache_object_for_issuer(val, issuer, now, keys, next_update);
204+
if (res == CacheLookupResult::ParseError) {
205+
return res;
206+
}
207+
if (res == CacheLookupResult::Found) {
208+
status = CacheLookupResult::Found;
209+
} else if (res == CacheLookupResult::Expired &&
210+
status == CacheLookupResult::NotFound) {
211+
status = CacheLookupResult::Expired;
212+
}
213+
}
214+
215+
return status;
216+
}
217+
218+
CacheLookupResult parse_cache_dir_for_issuer(const std::string &dir,
219+
const std::string &issuer,
220+
int64_t now, picojson::value &keys,
221+
int64_t &next_update) {
222+
bool expired_seen = false;
223+
auto prefix = issuer_hash_prefix(issuer);
224+
for (int idx = 0;; idx++) {
225+
std::ostringstream fname;
226+
fname << dir << "/" << prefix << "." << idx;
227+
228+
struct stat st;
229+
if (stat(fname.str().c_str(), &st) != 0) {
230+
break; // First missing file terminates search
231+
}
232+
if (!S_ISREG(st.st_mode)) {
233+
continue;
234+
}
235+
236+
auto res = parse_cache_file_for_issuer(fname.str(), issuer, now, keys,
237+
next_update);
238+
if (res == CacheLookupResult::Found ||
239+
res == CacheLookupResult::ParseError) {
240+
return res;
241+
}
242+
if (res == CacheLookupResult::Expired) {
243+
expired_seen = true;
244+
}
245+
}
246+
247+
if (expired_seen) {
248+
return CacheLookupResult::Expired;
249+
}
250+
return CacheLookupResult::NotFound;
251+
}
252+
253+
bool get_public_keys_from_system_cache(const std::string &issuer, int64_t now,
254+
picojson::value &keys,
255+
int64_t &next_update,
256+
bool &expired_hit) {
257+
258+
expired_hit = false;
259+
260+
struct Location {
261+
enum class Type { Dir, File } type;
262+
std::string path;
263+
};
264+
265+
std::vector<Location> search_order;
266+
const char *env_dir = getenv("JWKS_CACHE_DIR");
267+
const char *env_file = getenv("JWKS_CACHE_FILE");
268+
269+
if (env_dir && strlen(env_dir) > 0) {
270+
search_order.push_back({Location::Type::Dir, env_dir});
271+
}
272+
if (env_file && strlen(env_file) > 0) {
273+
search_order.push_back({Location::Type::File, env_file});
274+
}
275+
276+
search_order.push_back({Location::Type::Dir, "/etc/jwks"});
277+
search_order.push_back({Location::Type::File, "/etc/jwks/cache.json"});
278+
search_order.push_back({Location::Type::Dir, "/var/cache/jwks"});
279+
search_order.push_back(
280+
{Location::Type::File, "/var/cache/jwks/cache.json"});
281+
282+
for (const auto &loc : search_order) {
283+
struct stat st;
284+
if (stat(loc.path.c_str(), &st) != 0) {
285+
continue;
286+
}
287+
288+
CacheLookupResult res = CacheLookupResult::NotFound;
289+
if (loc.type == Location::Type::Dir) {
290+
if (!S_ISDIR(st.st_mode)) {
291+
continue;
292+
}
293+
res = parse_cache_dir_for_issuer(loc.path, issuer, now, keys,
294+
next_update);
295+
} else {
296+
if (!S_ISREG(st.st_mode)) {
297+
continue;
298+
}
299+
res = parse_cache_file_for_issuer(loc.path, issuer, now, keys,
300+
next_update);
301+
}
302+
303+
if (res == CacheLookupResult::Found) {
304+
auto &stats = scitokens::internal::MonitoringStats::instance()
305+
.get_issuer_stats(issuer);
306+
stats.inc_system_cache_hit();
307+
return true;
308+
}
309+
if (res == CacheLookupResult::ParseError) {
310+
throw scitokens::JsonException("Failed to parse JWKS cache at " +
311+
loc.path);
312+
}
313+
if (res == CacheLookupResult::Expired) {
314+
expired_hit = true;
315+
}
316+
}
317+
318+
return false;
319+
}
320+
28321
void initialize_cachedb(const std::string &keycache_file) {
29322

30323
sqlite3 *db;
@@ -159,6 +452,23 @@ bool scitokens::Validator::get_public_keys_from_db(const std::string issuer,
159452
int64_t now,
160453
picojson::value &keys,
161454
int64_t &next_update) {
455+
bool expired_hit = false;
456+
try {
457+
if (get_public_keys_from_system_cache(issuer, now, keys, next_update,
458+
expired_hit)) {
459+
return true;
460+
}
461+
} catch (const JsonException &) {
462+
throw;
463+
}
464+
465+
if (expired_hit) {
466+
auto &stats =
467+
scitokens::internal::MonitoringStats::instance().get_issuer_stats(
468+
issuer);
469+
stats.inc_system_cache_expired();
470+
}
471+
162472
auto cache_fname = get_cache_file();
163473
if (cache_fname.size() == 0) {
164474
return false;

src/scitokens_internal.h

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,10 @@ struct IssuerStats {
260260
std::atomic<uint64_t> failed_refreshes{0};
261261
std::atomic<uint64_t> stale_key_uses{0};
262262

263+
// System cache statistics
264+
std::atomic<uint64_t> system_cache_hits{0};
265+
std::atomic<uint64_t> system_cache_expired{0};
266+
263267
// Background refresh statistics (tracked by background thread)
264268
std::atomic<uint64_t> background_successful_refreshes{0};
265269
std::atomic<uint64_t> background_failed_refreshes{0};
@@ -307,6 +311,12 @@ struct IssuerStats {
307311
void inc_negative_cache_hit() {
308312
negative_cache_hits.fetch_add(1, std::memory_order_relaxed);
309313
}
314+
void inc_system_cache_hit() {
315+
system_cache_hits.fetch_add(1, std::memory_order_relaxed);
316+
}
317+
void inc_system_cache_expired() {
318+
system_cache_expired.fetch_add(1, std::memory_order_relaxed);
319+
}
310320

311321
// Time setters that accept std::chrono::duration (use relaxed ordering)
312322
template <typename Rep, typename Period>
@@ -362,6 +372,14 @@ struct IssuerStats {
362372
failed_key_lookup_time_ns.load(std::memory_order_relaxed)) /
363373
1e9;
364374
}
375+
376+
uint64_t get_system_cache_hits() const {
377+
return system_cache_hits.load(std::memory_order_relaxed);
378+
}
379+
380+
uint64_t get_system_cache_expired() const {
381+
return system_cache_expired.load(std::memory_order_relaxed);
382+
}
365383
};
366384

367385
/**

0 commit comments

Comments
 (0)