prole/pg-knoe-auth/test/unit/test_jwks_cache.c
chrisfu 57886f9268 feat(pg-knoe-auth): import upstream PostgreSQL JWT auth extension; compile in knoe-db image
- Copy pg-knoe-auth/ wholesale from upstream/knoe-db/20260523 (Task 1 of
  docs/plans/junie/upstream-knoe-db-20260523-integration.md).
- Extension: PG18 OAUTHBEARER JWT validator using libcurl + OpenSSL RS256.
- knoe-db/Dockerfile: add libcurl4-openssl-dev to dev deps; COPY src/ and
  build with make USE_PGXS=1 install after tds_fdw.
- NOT enabled in the default database build (absent from 20_create_extensions.sh).
  To enable: CREATE EXTENSION pg_knoe_auth; (requires pg_hba.conf oauth_issuer).

Closes Task 1 of upstream-knoe-db-20260523-integration.md.
2026-05-23 21:52:06 -07:00

168 lines
6.1 KiB
C

/*
* test_jwks_cache.c — unit tests for H2: JWKS process-local cache.
*
* Tests cache hit returns same pkey ptr, miss triggers fetch path,
* stale fallback logic, and eviction at JWK_CACHE_MAX entries.
* See docs/plans/junie/pg_knoe_auth-rename-harden-modularize.md §2.2.2
*/
#include "test_helpers.h"
/* Suppress OpenSSL 3.x deprecation warnings for RSA legacy API used in test stubs only */
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
#include <openssl/evp.h>
#include <openssl/rsa.h>
#include <openssl/bn.h>
#pragma GCC diagnostic pop
#include <time.h>
/* ── Inline cache implementation (same as pg_knoe_auth.c) ─────────────────── */
#define JWK_CACHE_MAX 32
#define JWK_CACHE_TTL_SECS 600
#define JWK_CACHE_STALE_SECS 86400
typedef struct {
char *issuer;
char *kid;
EVP_PKEY *pkey;
time_t fetched_at;
} JwkCacheEntry;
static JwkCacheEntry jwk_cache[JWK_CACHE_MAX];
static int jwk_cache_n = 0;
static int
jwk_cache_find(const char *issuer, const char *kid)
{
for (int i = 0; i < jwk_cache_n; i++) {
if (strcmp(jwk_cache[i].issuer, issuer) != 0) continue;
if (kid == NULL && jwk_cache[i].kid == NULL) return i;
if (kid != NULL && jwk_cache[i].kid != NULL &&
strcmp(jwk_cache[i].kid, kid) == 0) return i;
}
return -1;
}
static void
jwk_cache_store(const char *issuer, const char *kid, EVP_PKEY *pkey)
{
int idx = jwk_cache_find(issuer, kid);
if (idx < 0) {
if (jwk_cache_n < JWK_CACHE_MAX) {
idx = jwk_cache_n++;
} else {
idx = 0;
free(jwk_cache[0].issuer);
free(jwk_cache[0].kid);
EVP_PKEY_free(jwk_cache[0].pkey);
memmove(&jwk_cache[0], &jwk_cache[1],
(JWK_CACHE_MAX - 1) * sizeof(JwkCacheEntry));
jwk_cache_n = JWK_CACHE_MAX - 1;
idx = jwk_cache_n++;
}
} else {
free(jwk_cache[idx].issuer);
free(jwk_cache[idx].kid);
EVP_PKEY_free(jwk_cache[idx].pkey);
}
jwk_cache[idx].issuer = strdup(issuer);
jwk_cache[idx].kid = kid ? strdup(kid) : NULL;
jwk_cache[idx].pkey = pkey;
jwk_cache[idx].fetched_at = time(NULL);
}
/* Reset cache between tests */
static void cache_reset(void) {
for (int i = 0; i < jwk_cache_n; i++) {
free(jwk_cache[i].issuer);
free(jwk_cache[i].kid);
EVP_PKEY_free(jwk_cache[i].pkey);
}
jwk_cache_n = 0;
}
/* Make a throwaway RSA EVP_PKEY for testing (uses legacy RSA API — suppress deprecation) */
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
static EVP_PKEY *make_test_pkey(void) {
RSA *rsa = RSA_new();
BIGNUM *bn_n = BN_new();
BIGNUM *bn_e = BN_new();
EVP_PKEY *pkey = EVP_PKEY_new();
BN_set_word(bn_n, 0xDEADBEEF);
BN_set_word(bn_e, 65537);
RSA_set0_key(rsa, bn_n, bn_e, NULL);
EVP_PKEY_assign_RSA(pkey, rsa);
return pkey;
}
#pragma GCC diagnostic pop
int main(void)
{
const char *issuer = "https://api.0.knoe.dev/auth";
const char *kid1 = "key-001";
const char *kid2 = "key-002";
/* ── Test 1: cache miss returns -1 ─────────────────────────────────── */
cache_reset();
ASSERT(jwk_cache_find(issuer, kid1) == -1,
"cache miss returns -1 on empty cache");
/* ── Test 2: store + hit returns same index ─────────────────────────── */
EVP_PKEY *pk1 = make_test_pkey();
jwk_cache_store(issuer, kid1, pk1);
int idx = jwk_cache_find(issuer, kid1);
ASSERT(idx >= 0, "cache hit after store returns valid index");
ASSERT(jwk_cache[idx].pkey == pk1,
"cache hit returns the same pkey pointer");
/* ── Test 3: different kid is a miss ────────────────────────────────── */
ASSERT(jwk_cache_find(issuer, kid2) == -1,
"different kid is a cache miss");
/* ── Test 4: overwrite same (issuer, kid) updates fetched_at ────────── */
time_t before = time(NULL);
EVP_PKEY *pk1b = make_test_pkey();
jwk_cache_store(issuer, kid1, pk1b);
idx = jwk_cache_find(issuer, kid1);
ASSERT(idx >= 0 && jwk_cache[idx].pkey == pk1b,
"overwrite same key updates pkey pointer");
ASSERT(jwk_cache[idx].fetched_at >= before,
"overwrite updates fetched_at");
/* ── Test 5: stale detection — age >= TTL ───────────────────────────── */
jwk_cache[idx].fetched_at = time(NULL) - JWK_CACHE_TTL_SECS - 1;
time_t age = time(NULL) - jwk_cache[idx].fetched_at;
ASSERT(age >= JWK_CACHE_TTL_SECS,
"entry with age >= TTL is detected as stale");
/* ── Test 6: stale but within STALE_SECS — fallback window open ─────── */
jwk_cache[idx].fetched_at = time(NULL) - JWK_CACHE_TTL_SECS - 1;
age = time(NULL) - jwk_cache[idx].fetched_at;
ASSERT(age < JWK_CACHE_STALE_SECS,
"stale entry within 24h stale window is usable as fallback");
/* ── Test 7: eviction at JWK_CACHE_MAX ──────────────────────────────── */
cache_reset();
for (int i = 0; i < JWK_CACHE_MAX; i++) {
char kid_buf[32];
snprintf(kid_buf, sizeof(kid_buf), "kid-%03d", i);
jwk_cache_store(issuer, kid_buf, make_test_pkey());
}
ASSERT(jwk_cache_n == JWK_CACHE_MAX,
"cache fills to JWK_CACHE_MAX");
/* Adding one more should evict the oldest (kid-000) */
jwk_cache_store(issuer, "kid-new", make_test_pkey());
ASSERT(jwk_cache_n == JWK_CACHE_MAX,
"cache stays at JWK_CACHE_MAX after eviction");
ASSERT(jwk_cache_find(issuer, "kid-000") == -1,
"oldest entry (kid-000) was evicted");
ASSERT(jwk_cache_find(issuer, "kid-new") >= 0,
"new entry is present after eviction");
cache_reset();
TEST_SUMMARY();
}