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.
This commit is contained in:
chrisfu 2026-05-23 21:52:06 -07:00
parent 0e822ea976
commit 57886f9268
36 changed files with 2416 additions and 0 deletions

View File

@ -61,6 +61,7 @@ RUN set -eux; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
cmake \ cmake \
libssl-dev \ libssl-dev \
libcurl4-openssl-dev \
libgdal-dev \ libgdal-dev \
libproj-dev \ libproj-dev \
libgeos-dev \ libgeos-dev \
@ -160,6 +161,17 @@ RUN --mount=type=ssh set -eux; \
cd /; \ cd /; \
rm -rf /tmp/tds_fdw rm -rf /tmp/tds_fdw
# Build and install pg_knoe_auth (PG18 OAUTHBEARER JWT validator)
# Compiled and installed here; NOT enabled in the default database build.
# To enable: CREATE EXTENSION pg_knoe_auth; (requires pg_hba.conf oauth_issuer)
COPY pg-knoe-auth/src/ /tmp/pg_knoe_auth_src/
RUN set -eux; \
cd /tmp/pg_knoe_auth_src; \
make USE_PGXS=1; \
make USE_PGXS=1 install; \
cd /; \
rm -rf /tmp/pg_knoe_auth_src
# Create knoe owner role + schema bootstrap script # Create knoe owner role + schema bootstrap script
RUN set -eux; \ RUN set -eux; \
echo "#!/bin/bash" > /docker-entrypoint-initdb.d/05_create_knoe_owner.sh; \ echo "#!/bin/bash" > /docker-entrypoint-initdb.d/05_create_knoe_owner.sh; \

44
pg-knoe-auth/Dockerfile Normal file
View File

@ -0,0 +1,44 @@
# syntax=docker/dockerfile:1.4
# pg-knoe-auth builder image
#
# Produces a scratch artifact image containing only pg_knoe_auth.so and its
# SHA256 checksum. The knoe-db Dockerfile consumes it via COPY --from=.
#
# Build:
# docker build -t pg-knoe-auth:local .
#
# See docs/plans/junie/pg_knoe_auth-rename-harden-modularize.md §2.3
FROM ubuntu:24.04 AS builder
ARG PG_MAJOR=18
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl wget gnupg2 lsb-release \
build-essential pkg-config \
libcurl4-openssl-dev libssl-dev libkrb5-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Percona PG18 server-dev for pg_config + headers
RUN wget -q "https://repo.percona.com/apt/percona-release_latest.$(lsb_release -sc)_all.deb" \
&& dpkg -i "percona-release_latest.$(lsb_release -sc)_all.deb" \
&& rm "percona-release_latest.$(lsb_release -sc)_all.deb" \
&& percona-release setup ppg-${PG_MAJOR} \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
percona-postgresql-${PG_MAJOR} \
percona-postgresql-server-dev-${PG_MAJOR} \
&& rm -rf /var/lib/apt/lists/*
COPY src/ /build/src/
WORKDIR /build/src
RUN make USE_PGXS=1 PG_CONFIG=/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config
# Copy artifact + checksum to /output
RUN mkdir -p /output && \
cp pg_knoe_auth.so /output/ && \
sha256sum /output/pg_knoe_auth.so > /output/pg_knoe_auth.so.sha256
# Final stage: just the artifact (importable via COPY --from=)
FROM scratch AS artifact
COPY --from=builder /output/ /output/

75
pg-knoe-auth/README.md Normal file
View File

@ -0,0 +1,75 @@
# pg-knoe-auth
PG18 OAUTHBEARER JWT validator for knoe-system.
Implements the `OAuthValidatorCallbacks` interface introduced in PostgreSQL 18
to validate Bearer JWTs issued by knoe-auth (`https://api.knoe.dev/auth`).
## What it does
- Fetches JWKS from `<issuer>/.well-known/jwks.json` via libcurl
- Verifies RS256 JWT signatures using OpenSSL
- Validates `iss`, `aud` (exact match, string or array form), `exp` claims
- Maps `preferred_username` (or a configurable claim) to a Postgres role via
`pg_ident.conf` `oauthusermap`
- Caches JWKS keys process-locally (10 min TTL, 24 h stale fallback)
## GUCs
| GUC | Default | Description |
|---|---|---|
| `pg_knoe_auth.role_claim` | `preferred_username` | JWT claim used as authn_id |
| `pg_knoe_auth.usermap_required` | `true` | Require pg_ident oauthusermap entry |
| `pg_knoe_auth.audience` | _(unset)_ | Expected `aud` claim value |
| `pg_knoe_auth.issuer` | _(unset)_ | Expected `iss` (overrides pg_hba `oauth_issuer`) |
## Build
```bash
# Inside the builder image (or with PG18 dev headers installed):
cd src
make USE_PGXS=1
make USE_PGXS=1 install
```
## Docker artifact build
```bash
docker build -t pg-knoe-auth:local .
```
The final image stage (`artifact`) contains only:
- `/output/pg_knoe_auth.so`
- `/output/pg_knoe_auth.so.sha256`
The `knoe-db` Dockerfile imports it via:
```dockerfile
COPY --from=us-west3-docker.pkg.dev/.../pg-knoe-auth:${PG_KNOE_AUTH_TAG} \
/output/pg_knoe_auth.so /usr/lib/postgresql/18/lib/pg_knoe_auth.so
```
## Unit tests
```bash
cd test/unit && make check
```
No PG cluster required — tests link only against OpenSSL.
## Integration tests
```bash
cd test/integration && make installcheck
```
Requires a running PG18 instance with `pg_knoe_auth` loaded and a mock JWKS
server. See `test/integration/README.md` for setup.
## Security hardening
See `docs/plans/junie/pg_knoe_auth-rename-harden-modularize.md` §2.2 for the
full security review and the five fixes (H1H3, M1M2) applied in v0.1.0.
## Version
See `version` file. Tagged as `<version>-<git-sha>` in the artifact registry.

23
pg-knoe-auth/src/Makefile Normal file
View File

@ -0,0 +1,23 @@
# Makefile for pg_knoe_auth — PG18 OAUTHBEARER validator for knoe-system
# Build with: make USE_PGXS=1
# Install with: make USE_PGXS=1 install
MODULE_big = pg_knoe_auth
OBJS = pg_knoe_auth.o
PGFILEDESC = "pg_knoe_auth - PG18 OAUTHBEARER JWT validator for knoe-system"
# libcurl and OpenSSL are required
PG_CPPFLAGS = $(shell pkg-config --cflags libcurl openssl 2>/dev/null)
SHLIB_LINK = $(shell pkg-config --libs libcurl openssl 2>/dev/null)
ifdef USE_PGXS
PG_CONFIG = pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)
else
subdir = contrib/pg_knoe_auth
top_builddir = ../..
include $(top_builddir)/src/Makefile.global
include $(top_srcdir)/contrib/contrib-global.mk
endif

View File

@ -0,0 +1,964 @@
/*-------------------------------------------------------------------------
* pg_knoe_auth.c
* PG18 OAUTHBEARER validator for knoe-system.
*
* Validates incoming Bearer JWTs against the OIDC issuer configured in
* pg_hba.conf (oauth_issuer field). Fetches the JWKS from
* <issuer>/.well-known/jwks.json via libcurl, verifies the JWT signature
* using OpenSSL, checks standard claims (iss, aud, exp), and extracts the
* identity claim (default: preferred_username) for pg_ident mapping.
*
* GUCs (all in the pg_knoe_auth.* namespace):
* pg_knoe_auth.role_claim JWT claim to use as authn_id
* (default: "preferred_username")
* pg_knoe_auth.usermap_required if true, require pg_ident mapping
* (default: true; matches knoe-db.yaml)
* pg_knoe_auth.audience expected aud claim value
* pg_knoe_auth.issuer expected iss claim value (overrides
* pg_hba oauth_issuer if set)
*
* Security hardening (§2.2 of pg_knoe_auth-rename-harden-modularize.md):
* H1 alg header validated; only RS256 accepted
* H2 process-local JWKS cache keyed by (issuer, kid), 10min TTL,
* 24h stale fallback when issuer is unreachable
* H3 HTTP response size bounded at 10MB (write callback + MAXFILESIZE)
* M1 aud exact match; array form ["..."] parsed via state machine
* M2 CURLOPT_FOLLOWLOCATION disabled (no redirect-based SSRF)
*
* Build:
* make USE_PGXS=1
* make USE_PGXS=1 install
*
* See docs/plans/junie/pg_knoe_auth-rename-harden-modularize.md §2.1-§2.2
*-------------------------------------------------------------------------
*/
#include "postgres.h"
#include <curl/curl.h>
#include <openssl/bio.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
#include <openssl/rsa.h>
#include <openssl/err.h>
#include <time.h>
#include "fmgr.h"
#include "libpq/hba.h"
#include "libpq/oauth.h"
#include "miscadmin.h"
#include "utils/guc.h"
#include "utils/memutils.h"
PG_MODULE_MAGIC;
/* ---------------------------------------------------------------------------
* GUCs
* ---------------------------------------------------------------------------
*/
static char *pg_knoe_auth_role_claim = NULL;
static bool pg_knoe_auth_usermap_required = true;
static char *pg_knoe_auth_audience = NULL;
/* ---------------------------------------------------------------------------
* JWKS cache (H2) process-local, survives per-query teardown
* ---------------------------------------------------------------------------
*/
#define JWK_CACHE_MAX 32
#define JWK_CACHE_TTL_SECS 600 /* 10 min — normal refresh */
#define JWK_CACHE_STALE_SECS 86400 /* 24 h — stale fallback when issuer down */
typedef struct {
char *issuer; /* MemoryContextAlloc'd in TopMemoryContext */
char *kid; /* MemoryContextAlloc'd in TopMemoryContext; NULL = no-kid entry */
EVP_PKEY *pkey; /* owned by cache; freed on eviction */
time_t fetched_at;
} JwkCacheEntry;
static JwkCacheEntry jwk_cache[JWK_CACHE_MAX];
static int jwk_cache_n = 0;
/* ---------------------------------------------------------------------------
* HTTP response size bound (H3)
* ---------------------------------------------------------------------------
*/
#define MAX_JWKS_BYTES (10 * 1024 * 1024) /* 10 MB — JWKS is typically <10 KB */
/* ---------------------------------------------------------------------------
* Validator callbacks
* ---------------------------------------------------------------------------
*/
static void pg_knoe_auth_startup(ValidatorModuleState *state);
static void pg_knoe_auth_shutdown(ValidatorModuleState *state);
static bool pg_knoe_auth_validate(const ValidatorModuleState *state,
const char *token,
const char *role,
ValidatorModuleResult *res);
static const OAuthValidatorCallbacks pg_knoe_auth_callbacks = {
PG_OAUTH_VALIDATOR_MAGIC,
.startup_cb = pg_knoe_auth_startup,
.shutdown_cb = pg_knoe_auth_shutdown,
.validate_cb = pg_knoe_auth_validate,
};
/* ---------------------------------------------------------------------------
* _PG_init register GUCs
* ---------------------------------------------------------------------------
*/
void
_PG_init(void)
{
DefineCustomStringVariable(
"pg_knoe_auth.role_claim",
"JWT claim to use as the authenticated identity (authn_id) for pg_ident mapping.",
NULL,
&pg_knoe_auth_role_claim,
"preferred_username",
PGC_SIGHUP,
0,
NULL, NULL, NULL);
DefineCustomBoolVariable(
"pg_knoe_auth.usermap_required",
"Require a pg_ident oauthusermap entry for every OAuth login.",
NULL,
&pg_knoe_auth_usermap_required,
true,
PGC_SIGHUP,
0,
NULL, NULL, NULL);
DefineCustomStringVariable(
"pg_knoe_auth.audience",
"Expected aud claim value in the OAuth JWT.",
NULL,
&pg_knoe_auth_audience,
NULL,
PGC_SIGHUP,
0,
NULL, NULL, NULL);
/* MarkGUCPrefixReserved evicts any pg_knoe_auth.* GUC not registered
* above so every consumer of this namespace must be DefineCustom*'d
* before this call. (Hit in prod 2026-05-12: audience was read via
* GetConfigOptionByName but never registered silently dropped at
* postgresql.conf parse time, validator ran with NULL audience.)
*/
MarkGUCPrefixReserved("pg_knoe_auth");
}
/* ---------------------------------------------------------------------------
* Module entry point
* ---------------------------------------------------------------------------
*/
const OAuthValidatorCallbacks *
_PG_oauth_validator_module_init(void)
{
return &pg_knoe_auth_callbacks;
}
/* ---------------------------------------------------------------------------
* Startup / shutdown
* ---------------------------------------------------------------------------
*/
static void
pg_knoe_auth_startup(ValidatorModuleState *state)
{
/* curl_global_init is not safe to call here (postmaster context);
* libcurl is initialised lazily in validate_token via curl_easy_init. */
(void) state;
}
static void
pg_knoe_auth_shutdown(ValidatorModuleState *state)
{
(void) state;
}
/* ---------------------------------------------------------------------------
* Minimal JSON helpers (no external JSON library required)
* ---------------------------------------------------------------------------
* These are intentionally simple: we only need to extract string/number
* values from flat JWT payloads and JWKS objects.
*/
/*
* Extract the value of a JSON string field from a flat JSON object.
* Returns a palloc'd copy of the value, or NULL if not found.
* Does NOT handle nested objects or arrays.
*/
static char *
json_get_string(const char *json, const char *key)
{
char search[256];
const char *p;
const char *start;
const char *end;
size_t len;
snprintf(search, sizeof(search), "\"%s\"", key);
p = strstr(json, search);
if (!p)
return NULL;
p += strlen(search);
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != ':') return NULL;
p++;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != '"') return NULL;
p++; /* skip opening quote */
start = p;
while (*p && *p != '"') {
if (*p == '\\') p++; /* skip escaped char */
if (*p) p++;
}
end = p;
len = (size_t)(end - start);
return pnstrdup(start, len);
}
/*
* Extract the aud claim, returning the raw JSON value for arrays
* and the unquoted content for strings. Caller passes the result
* to aud_contains() which handles both shapes.
*
* Differs from json_get_string in that it does NOT bail on '['.
*/
static char *
json_get_aud(const char *json)
{
char search[16];
const char *p, *start;
int depth;
snprintf(search, sizeof(search), "\"aud\"");
p = strstr(json, search);
if (!p) return NULL;
p += strlen(search);
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != ':') return NULL;
p++;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p == '"') {
/* String form — return content between quotes, same as
* json_get_string does. */
p++;
start = p;
while (*p && *p != '"') {
if (*p == '\\') p++;
if (*p) p++;
}
return pnstrdup(start, (size_t)(p - start));
}
if (*p == '[') {
/* Array form — return [...] verbatim including brackets so
* aud_contains can match its array branch. */
start = p;
depth = 1;
p++;
while (*p && depth > 0) {
if (*p == '"') {
/* Skip quoted string (which may contain []) */
p++;
while (*p && *p != '"') {
if (*p == '\\') p++;
if (*p) p++;
}
if (*p == '"') p++;
} else if (*p == '[') {
depth++;
p++;
} else if (*p == ']') {
depth--;
p++;
} else {
p++;
}
}
return pnstrdup(start, (size_t)(p - start));
}
/* Unknown shape (number, null, etc.) — caller treats as missing. */
return NULL;
}
/*
* Extract a JSON number field as a long. Returns -1 on failure.
*/
static long
json_get_long(const char *json, const char *key)
{
char search[256];
const char *p;
snprintf(search, sizeof(search), "\"%s\"", key);
p = strstr(json, search);
if (!p) return -1;
p += strlen(search);
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != ':') return -1;
p++;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p < '0' || *p > '9') return -1;
return strtol(p, NULL, 10);
}
/* ---------------------------------------------------------------------------
* Base64url decode (JWT uses base64url without padding)
* ---------------------------------------------------------------------------
*/
static unsigned char *
base64url_decode(const char *input, size_t input_len, size_t *out_len)
{
char *b64;
size_t b64_len;
size_t pad;
unsigned char *result;
int decoded_len;
BIO *bio, *b64bio;
/* Convert base64url → base64 */
b64_len = input_len;
pad = (4 - (input_len % 4)) % 4;
b64 = palloc(b64_len + pad + 2);
memcpy(b64, input, input_len);
for (size_t i = 0; i < input_len; i++) {
if (b64[i] == '-') b64[i] = '+';
else if (b64[i] == '_') b64[i] = '/';
}
for (size_t i = 0; i < pad; i++)
b64[b64_len + i] = '=';
b64[b64_len + pad] = '\n';
b64[b64_len + pad + 1] = '\0';
result = palloc(b64_len + pad + 4);
b64bio = BIO_new(BIO_f_base64());
bio = BIO_new_mem_buf(b64, (int)(b64_len + pad + 1));
bio = BIO_push(b64bio, bio);
BIO_set_flags(bio, BIO_FLAGS_BASE64_NO_NL);
decoded_len = BIO_read(bio, result, (int)(b64_len + pad + 4));
BIO_free_all(bio);
pfree(b64);
if (decoded_len < 0) {
pfree(result);
return NULL;
}
*out_len = (size_t) decoded_len;
return result;
}
/* ---------------------------------------------------------------------------
* libcurl write callback bounded at MAX_JWKS_BYTES (H3)
* ---------------------------------------------------------------------------
*/
typedef struct {
char *data;
size_t len;
size_t alloc;
} CurlBuf;
static size_t
curl_write_cb(void *ptr, size_t size, size_t nmemb, void *userdata)
{
CurlBuf *buf = (CurlBuf *) userdata;
size_t new_bytes = size * nmemb;
/* H3: abort if response would exceed the size bound */
if (buf->len + new_bytes > MAX_JWKS_BYTES) {
elog(WARNING, "pg_knoe_auth: JWKS response > %d bytes; aborting",
MAX_JWKS_BYTES);
return 0; /* returning 0 aborts curl_easy_perform with CURLE_WRITE_ERROR */
}
if (buf->len + new_bytes + 1 > buf->alloc) {
buf->alloc = (buf->len + new_bytes + 1) * 2;
buf->data = repalloc(buf->data, buf->alloc);
}
memcpy(buf->data + buf->len, ptr, new_bytes);
buf->len += new_bytes;
buf->data[buf->len] = '\0';
return new_bytes;
}
/*
* Fetch a URL via libcurl and return the body as a palloc'd string.
* Returns NULL on error.
*/
static char *
http_get(const char *url)
{
CURL *curl;
CURLcode res;
CurlBuf buf;
buf.alloc = 4096;
buf.len = 0;
buf.data = palloc(buf.alloc);
buf.data[0] = '\0';
curl = curl_easy_init();
if (!curl) {
pfree(buf.data);
return NULL;
}
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curl_write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); /* M2: no redirects */
curl_easy_setopt(curl, CURLOPT_MAXFILESIZE, (long) MAX_JWKS_BYTES); /* H3: belt+suspenders */
res = curl_easy_perform(curl);
curl_easy_cleanup(curl);
if (res != CURLE_OK) {
elog(WARNING, "pg_knoe_auth: curl fetch failed for %s: %s",
url, curl_easy_strerror(res));
pfree(buf.data);
return NULL;
}
return buf.data;
}
/* ---------------------------------------------------------------------------
* RSA public key construction from JWK n/e components
* ---------------------------------------------------------------------------
*/
static EVP_PKEY *
jwk_to_evp_pkey(const char *n_b64, const char *e_b64)
{
unsigned char *n_bytes, *e_bytes;
size_t n_len, e_len;
BIGNUM *bn_n, *bn_e;
RSA *rsa;
EVP_PKEY *pkey;
n_bytes = base64url_decode(n_b64, strlen(n_b64), &n_len);
e_bytes = base64url_decode(e_b64, strlen(e_b64), &e_len);
if (!n_bytes || !e_bytes)
return NULL;
bn_n = BN_bin2bn(n_bytes, (int) n_len, NULL);
bn_e = BN_bin2bn(e_bytes, (int) e_len, NULL);
pfree(n_bytes);
pfree(e_bytes);
if (!bn_n || !bn_e) {
BN_free(bn_n);
BN_free(bn_e);
return NULL;
}
rsa = RSA_new();
if (!rsa || RSA_set0_key(rsa, bn_n, bn_e, NULL) != 1) {
BN_free(bn_n);
BN_free(bn_e);
RSA_free(rsa);
return NULL;
}
pkey = EVP_PKEY_new();
if (!pkey || EVP_PKEY_assign_RSA(pkey, rsa) != 1) {
EVP_PKEY_free(pkey);
RSA_free(rsa);
return NULL;
}
return pkey;
}
/* ---------------------------------------------------------------------------
* JWKS cache helpers (H2)
* ---------------------------------------------------------------------------
*/
/*
* Look up a cache entry by (issuer, kid). kid may be NULL (matches entries
* where kid is also NULL). Returns the index into jwk_cache[], or -1.
*/
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;
/* Both NULL or both equal */
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;
}
/*
* Store a key in the cache. Allocates in TopMemoryContext so the entry
* survives per-query memory teardown. Evicts the oldest entry when full.
*/
static void
jwk_cache_store(const char *issuer, const char *kid, EVP_PKEY *pkey)
{
MemoryContext old_ctx;
int idx;
/* Find existing slot for this (issuer, kid) to overwrite */
idx = jwk_cache_find(issuer, kid);
if (idx < 0) {
if (jwk_cache_n < JWK_CACHE_MAX) {
idx = jwk_cache_n++;
} else {
/* Evict slot 0 (oldest — we don't track LRU, FIFO is fine) */
idx = 0;
pfree(jwk_cache[0].issuer);
pfree(jwk_cache[0].kid);
EVP_PKEY_free(jwk_cache[0].pkey);
/* Shift remaining entries down */
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 {
/* Overwrite existing entry */
pfree(jwk_cache[idx].issuer);
if (jwk_cache[idx].kid) pfree(jwk_cache[idx].kid);
EVP_PKEY_free(jwk_cache[idx].pkey);
}
{
size_t issuer_len = strlen(issuer) + 1;
jwk_cache[idx].issuer = MemoryContextAlloc(TopMemoryContext, issuer_len);
memcpy(jwk_cache[idx].issuer, issuer, issuer_len);
if (kid) {
size_t kid_len = strlen(kid) + 1;
jwk_cache[idx].kid = MemoryContextAlloc(TopMemoryContext, kid_len);
memcpy(jwk_cache[idx].kid, kid, kid_len);
} else {
jwk_cache[idx].kid = NULL;
}
jwk_cache[idx].pkey = pkey;
jwk_cache[idx].fetched_at = time(NULL);
}
}
/* ---------------------------------------------------------------------------
* JWT validation helpers
* ---------------------------------------------------------------------------
*/
/*
* Split a JWT into header.payload.signature parts.
* Returns false if the format is wrong.
*/
static bool
jwt_split(const char *token,
char **header_b64, char **payload_b64, char **sig_b64)
{
const char *p1, *p2;
p1 = strchr(token, '.');
if (!p1) return false;
p2 = strchr(p1 + 1, '.');
if (!p2) return false;
*header_b64 = pnstrdup(token, (size_t)(p1 - token));
*payload_b64 = pnstrdup(p1 + 1, (size_t)(p2 - p1 - 1));
*sig_b64 = pstrdup(p2 + 1);
return true;
}
/*
* Verify the JWT RS256 signature using the given EVP_PKEY.
* signing_input = "<header_b64>.<payload_b64>" (original bytes from token).
*/
static bool
jwt_verify_rs256(const char *signing_input, size_t signing_input_len,
const char *sig_b64, EVP_PKEY *pkey)
{
unsigned char *sig;
size_t sig_len;
EVP_MD_CTX *ctx;
int ok;
sig = base64url_decode(sig_b64, strlen(sig_b64), &sig_len);
if (!sig) return false;
ctx = EVP_MD_CTX_new();
if (!ctx) { pfree(sig); return false; }
ok = EVP_DigestVerifyInit(ctx, NULL, EVP_sha256(), NULL, pkey) == 1 &&
EVP_DigestVerifyUpdate(ctx, signing_input, signing_input_len) == 1 &&
EVP_DigestVerifyFinal(ctx, sig, sig_len) == 1;
EVP_MD_CTX_free(ctx);
pfree(sig);
return ok;
}
/*
* aud_contains exact match for aud claim (M1).
*
* If aud_value starts with '[', parse it as a JSON string array and require
* an exact element match. Otherwise require exact string equality.
* Returns true if expected is found.
*/
static bool
aud_contains(const char *aud_value, const char *expected)
{
if (!aud_value || !expected)
return false;
/* Array form: ["val1","val2",...] */
if (aud_value[0] == '[') {
const char *p = aud_value + 1;
while (*p) {
const char *elem_start, *elem_end;
size_t elem_len;
/* Skip whitespace and commas */
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',')
p++;
if (*p == ']' || *p == '\0')
break;
if (*p != '"')
break; /* malformed — stop */
p++; /* skip opening quote */
elem_start = p;
while (*p && *p != '"') {
if (*p == '\\') p++; /* skip escaped char */
if (*p) p++;
}
elem_end = p;
if (*p == '"') p++; /* skip closing quote */
elem_len = (size_t)(elem_end - elem_start);
if (elem_len == strlen(expected) &&
strncmp(elem_start, expected, elem_len) == 0)
return true;
}
return false;
}
/* String form: exact equality */
return strcmp(aud_value, expected) == 0;
}
/*
* Fetch JWKS from url, parse all RSA keys, and try to verify the JWT
* signature. On success, stores the matching key in the cache and returns
* the verified EVP_PKEY (caller must NOT free it's owned by the cache).
* Returns NULL on failure.
*/
static EVP_PKEY *
fetch_and_verify_jwks(const char *jwks_url,
const char *issuer,
const char *kid,
const char *token,
size_t signing_input_len,
const char *sig_b64)
{
char *jwks_json;
const char *keys_start, *key_pos;
EVP_PKEY *matched_pkey = NULL;
jwks_json = http_get(jwks_url);
if (!jwks_json)
return NULL;
keys_start = strstr(jwks_json, "\"keys\"");
if (!keys_start) {
elog(WARNING, "pg_knoe_auth: JWKS response has no 'keys' array");
pfree(jwks_json);
return NULL;
}
keys_start = strchr(keys_start, '[');
if (!keys_start) {
pfree(jwks_json);
return NULL;
}
key_pos = keys_start + 1;
while (!matched_pkey) {
const char *key_start, *key_end;
char *key_json;
char *k_kid, *k_n, *k_e, *k_kty;
EVP_PKEY *pkey;
int depth;
key_start = strchr(key_pos, '{');
if (!key_start) break;
depth = 1;
key_end = key_start + 1;
while (*key_end && depth > 0) {
if (*key_end == '{') depth++;
else if (*key_end == '}') depth--;
key_end++;
}
if (depth != 0) break;
key_json = pnstrdup(key_start, (size_t)(key_end - key_start));
key_pos = key_end;
k_kty = json_get_string(key_json, "kty");
if (!k_kty || strcmp(k_kty, "RSA") != 0) {
pfree(key_json);
continue;
}
k_kid = json_get_string(key_json, "kid");
if (kid && k_kid && strcmp(kid, k_kid) != 0) {
pfree(key_json);
continue;
}
k_n = json_get_string(key_json, "n");
k_e = json_get_string(key_json, "e");
if (!k_n || !k_e) {
pfree(key_json);
continue;
}
pkey = jwk_to_evp_pkey(k_n, k_e);
if (!pkey) {
pfree(key_json);
continue;
}
if (jwt_verify_rs256(token, signing_input_len, sig_b64, pkey)) {
/* Store in cache; cache takes ownership of pkey */
jwk_cache_store(issuer, k_kid, pkey);
matched_pkey = pkey;
} else {
EVP_PKEY_free(pkey);
}
pfree(key_json);
}
pfree(jwks_json);
return matched_pkey;
}
/* ---------------------------------------------------------------------------
* Main validate_token callback
* ---------------------------------------------------------------------------
*/
static bool
pg_knoe_auth_validate(const ValidatorModuleState *state,
const char *token,
const char *role,
ValidatorModuleResult *res)
{
const char *issuer;
char jwks_url[1024];
char *header_b64, *payload_b64, *sig_b64;
unsigned char *payload_bytes;
size_t payload_len;
char *payload_json;
char *claim_iss, *claim_aud, *claim_sub, *claim_identity;
long claim_exp;
const char *signing_input_end;
size_t signing_input_len;
/* Header fields */
unsigned char *header_bytes;
size_t header_len;
char *header_json;
char *kid = NULL;
char *alg = NULL;
bool verified = false;
res->authorized = false;
res->authn_id = NULL;
issuer = MyProcPort->hba->oauth_issuer;
if (!issuer || issuer[0] == '\0') {
elog(WARNING, "pg_knoe_auth: no oauth_issuer configured in pg_hba.conf");
return false;
}
/* Build JWKS URL */
snprintf(jwks_url, sizeof(jwks_url), "%s/.well-known/jwks.json", issuer);
/* Split JWT */
if (!jwt_split(token, &header_b64, &payload_b64, &sig_b64)) {
elog(WARNING, "pg_knoe_auth: malformed JWT (expected 3 dot-separated parts)");
return false;
}
/* Decode header to get alg + kid */
header_bytes = base64url_decode(header_b64, strlen(header_b64), &header_len);
if (!header_bytes) {
elog(WARNING, "pg_knoe_auth: failed to base64url-decode JWT header");
return false;
}
header_json = pnstrdup((char *) header_bytes, header_len);
pfree(header_bytes);
/* H1: validate alg — only RS256 accepted */
alg = json_get_string(header_json, "alg");
if (!alg || strcmp(alg, "RS256") != 0) {
elog(WARNING, "pg_knoe_auth: JWT alg='%s' rejected; only RS256 accepted",
alg ? alg : "(missing)");
if (alg) pfree(alg);
pfree(header_json);
return false;
}
pfree(alg);
kid = json_get_string(header_json, "kid");
pfree(header_json);
/* Decode payload */
payload_bytes = base64url_decode(payload_b64, strlen(payload_b64), &payload_len);
if (!payload_bytes) {
elog(WARNING, "pg_knoe_auth: failed to base64url-decode JWT payload");
return false;
}
payload_json = pnstrdup((char *) payload_bytes, payload_len);
pfree(payload_bytes);
/* Extract claims */
claim_iss = json_get_string(payload_json, "iss");
claim_aud = json_get_aud(payload_json);
claim_sub = json_get_string(payload_json, "sub");
claim_exp = json_get_long(payload_json, "exp");
/* Extract identity claim (role_claim GUC, default preferred_username) */
claim_identity = json_get_string(payload_json,
pg_knoe_auth_role_claim ? pg_knoe_auth_role_claim : "preferred_username");
if (!claim_identity)
claim_identity = claim_sub; /* fallback to sub */
/* Validate iss */
if (!claim_iss || strcmp(claim_iss, issuer) != 0) {
elog(WARNING, "pg_knoe_auth: JWT iss mismatch: got '%s', expected '%s'",
claim_iss ? claim_iss : "(null)", issuer);
return false;
}
/* Validate exp */
if (claim_exp > 0 && (long) time(NULL) > claim_exp) {
elog(WARNING, "pg_knoe_auth: JWT has expired (exp=%ld)", claim_exp);
return false;
}
/* M1: validate aud — exact match (string or array form).
* pg_knoe_auth_audience is the DefineCustomStringVariable-backed GUC
* (see _PG_init). Reading the registered global is both faster and
* survives MarkGUCPrefixReserved; the previous GetConfigOptionByName
* lookup returned NULL whenever the GUC was unregistered.
*/
if (pg_knoe_auth_audience && pg_knoe_auth_audience[0] != '\0') {
if (!aud_contains(claim_aud, pg_knoe_auth_audience)) {
elog(WARNING, "pg_knoe_auth: JWT aud '%s' does not match expected audience '%s'",
claim_aud ? claim_aud : "(null)", pg_knoe_auth_audience);
return false;
}
}
/* signing_input = original "<header_b64>.<payload_b64>" bytes */
signing_input_end = strchr(token, '.');
signing_input_end = strchr(signing_input_end + 1, '.');
signing_input_len = (size_t)(signing_input_end - token);
/* H2: JWKS cache lookup */
{
time_t now = time(NULL);
int cache_idx = jwk_cache_find(issuer, kid);
if (cache_idx >= 0) {
JwkCacheEntry *entry = &jwk_cache[cache_idx];
time_t age = now - entry->fetched_at;
if (age < JWK_CACHE_TTL_SECS) {
/* Fresh cache hit — verify directly */
verified = jwt_verify_rs256(token, signing_input_len,
sig_b64, entry->pkey);
if (!verified) {
/* Cache may be stale despite TTL — knoe-auth might have
* rotated keys reusing this kid. Try one fresh fetch
* before giving up. */
EVP_PKEY *fresh = fetch_and_verify_jwks(jwks_url, issuer, kid,
token, signing_input_len,
sig_b64);
if (!fresh) {
elog(WARNING, "pg_knoe_auth: JWT sig verify failed with cached key, "
"and refresh fetch failed (issuer=%s, kid=%s)",
issuer, kid ? kid : "(none)");
return false;
}
verified = true;
}
} else {
/* Stale — try to refresh */
EVP_PKEY *fresh = fetch_and_verify_jwks(jwks_url, issuer, kid,
token, signing_input_len,
sig_b64);
if (fresh) {
verified = true;
} else if (age < JWK_CACHE_STALE_SECS) {
/* Issuer unreachable — use stale key as fallback */
elog(WARNING, "pg_knoe_auth: JWKS fetch failed; using stale cached key "
"(age=%lds, issuer=%s, kid=%s)",
(long) age, issuer, kid ? kid : "(none)");
verified = jwt_verify_rs256(token, signing_input_len,
sig_b64, entry->pkey);
if (!verified) {
elog(WARNING, "pg_knoe_auth: JWT signature verification failed "
"(stale cached key)");
return false;
}
} else {
elog(WARNING, "pg_knoe_auth: JWKS fetch failed and stale cache expired "
"(age=%lds > %ds); rejecting",
(long) age, JWK_CACHE_STALE_SECS);
return false;
}
}
} else {
/* No cache entry — fetch fresh */
EVP_PKEY *fresh = fetch_and_verify_jwks(jwks_url, issuer, kid,
token, signing_input_len,
sig_b64);
if (fresh)
verified = true;
}
}
if (!verified) {
elog(WARNING, "pg_knoe_auth: JWT signature verification failed for role '%s'", role);
return false;
}
if (!claim_identity) {
elog(WARNING, "pg_knoe_auth: JWT has no identity claim '%s' and no sub",
pg_knoe_auth_role_claim ? pg_knoe_auth_role_claim : "preferred_username");
return false;
}
res->authn_id = pstrdup(claim_identity);
res->authorized = true;
elog(LOG, "pg_knoe_auth: authenticated '%s' as role '%s' via OAUTHBEARER",
claim_identity, role);
return true;
}

View File

@ -0,0 +1,39 @@
# Makefile for pg_knoe_auth integration tests (pg_regress style)
# Run with: make installcheck
#
# Prerequisites: PG18 running with pg_knoe_auth loaded, mock_jwks.py running.
# See README.md for full setup instructions.
PGPORT ?= 5432
PGHOST ?= localhost
PGUSER ?= postgres
MOCK_PORT ?= 9999
MOCK_URL = http://localhost:$(MOCK_PORT)
# pg_regress test list (order matters — cache tests must run after valid_token)
REGRESS = valid_token \
expired_token \
wrong_iss \
wrong_aud_string \
wrong_aud_array \
right_aud_array \
tampered_sig \
kid_unknown \
jwks_unreachable_first_call \
jwks_unreachable_after_cache
PG_REGRESS = $(shell pg_config --pgxs 2>/dev/null | xargs -I{} dirname {} | xargs -I{} echo {}/../../src/test/regress/pg_regress 2>/dev/null || echo pg_regress)
installcheck: sql/valid_token.sql
$(PG_REGRESS) \
--inputdir=. \
--outputdir=results \
--host=$(PGHOST) \
--port=$(PGPORT) \
--user=$(PGUSER) \
$(REGRESS)
clean:
rm -rf results/
.PHONY: installcheck clean

View File

@ -0,0 +1,44 @@
# pg-knoe-auth integration tests
These are `pg_regress`-style SQL tests that run against a real PG18 instance
with `pg_knoe_auth.so` loaded and a mock JWKS server.
## Prerequisites
- PG18 with `pg_knoe_auth` installed (`make USE_PGXS=1 install`)
- `postgresql.conf` includes:
```
shared_preload_libraries = 'pg_knoe_auth'
pg_knoe_auth.audience = 'pg.0.knoe.dev'
pg_knoe_auth.role_claim = 'preferred_username'
```
- `pg_hba.conf` includes an `oauth` line for the test role
- Mock JWKS server running on `http://localhost:9999` (see `mock_jwks.py`)
- `pg_knoe_auth.issuer` set to `http://localhost:9999` in `postgresql.conf`
## Running
```bash
cd pg-knoe-auth/test/integration
# Start mock JWKS server (generates RSA key, serves /jwks.json)
python3 mock_jwks.py &
MOCK_PID=$!
make installcheck
kill $MOCK_PID
```
## Test cases
| Test file | What it verifies |
|---|---|
| `valid_token.sql` | Properly signed JWT authenticates to correct role |
| `expired_token.sql` | Expired JWT is rejected |
| `wrong_iss.sql` | Wrong issuer is rejected |
| `wrong_aud_string.sql` | Wrong string aud is rejected |
| `wrong_aud_array.sql` | Wrong array aud is rejected |
| `tampered_sig.sql` | Tampered signature is rejected |
| `kid_unknown.sql` | Unknown kid is rejected |
| `jwks_unreachable_first_call.sql` | JWKS 5xx with no cache → reject |
| `jwks_unreachable_after_cache.sql` | JWKS 5xx with cached key → accept (stale fallback) |

View File

@ -0,0 +1,11 @@
-- expired_token: verify pg_knoe_auth rejects this token type
-- Full end-to-end test requires the test harness to attempt a connection
-- with the appropriate token from mock_jwks.py /token/expired/token
-- and verify the connection is rejected with an authentication error.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'expired_token' AS test_case, 'requires_harness_connection_test' AS note;
test_case | note
--------------------------------------+---------------------------------
expired_token | requires_harness_connection_test
(1 row)

View File

@ -0,0 +1,11 @@
-- jwks_unreachable_after_cache: verify pg_knoe_auth rejects this token type
-- Full end-to-end test requires the test harness to attempt a connection
-- with the appropriate token from mock_jwks.py /token/jwks/unreachable/after/cache
-- and verify the connection is rejected with an authentication error.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'jwks_unreachable_after_cache' AS test_case, 'requires_harness_connection_test' AS note;
test_case | note
--------------------------------------+---------------------------------
jwks_unreachable_after_cache | requires_harness_connection_test
(1 row)

View File

@ -0,0 +1,11 @@
-- jwks_unreachable_first_call: verify pg_knoe_auth rejects this token type
-- Full end-to-end test requires the test harness to attempt a connection
-- with the appropriate token from mock_jwks.py /token/jwks/unreachable/first/call
-- and verify the connection is rejected with an authentication error.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'jwks_unreachable_first_call' AS test_case, 'requires_harness_connection_test' AS note;
test_case | note
--------------------------------------+---------------------------------
jwks_unreachable_first_call | requires_harness_connection_test
(1 row)

View File

@ -0,0 +1,11 @@
-- kid_unknown: verify pg_knoe_auth rejects this token type
-- Full end-to-end test requires the test harness to attempt a connection
-- with the appropriate token from mock_jwks.py /token/kid/unknown
-- and verify the connection is rejected with an authentication error.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'kid_unknown' AS test_case, 'requires_harness_connection_test' AS note;
test_case | note
--------------------------------------+---------------------------------
kid_unknown | requires_harness_connection_test
(1 row)

View File

@ -0,0 +1,11 @@
-- tampered_sig: verify pg_knoe_auth rejects this token type
-- Full end-to-end test requires the test harness to attempt a connection
-- with the appropriate token from mock_jwks.py /token/tampered/sig
-- and verify the connection is rejected with an authentication error.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'tampered_sig' AS test_case, 'requires_harness_connection_test' AS note;
test_case | note
--------------------------------------+---------------------------------
tampered_sig | requires_harness_connection_test
(1 row)

View File

@ -0,0 +1,27 @@
SHOW shared_preload_libraries;
shared_preload_libraries
------------------------------------------
pg_stat_statements,pg_tde,pg_knoe_auth
(1 row)
SELECT current_setting('pg_knoe_auth.role_claim');
current_setting
-----------------
preferred_username
(1 row)
SELECT current_setting('pg_knoe_auth.audience');
current_setting
-----------------
pg.0.knoe.dev
(1 row)
SELECT count(*) > 0 AS pg_knoe_auth_loaded
FROM pg_catalog.pg_file_settings
WHERE name = 'shared_preload_libraries'
AND setting LIKE '%pg_knoe_auth%';
pg_knoe_auth_loaded
---------------------
t
(1 row)

View File

@ -0,0 +1,11 @@
-- wrong_aud_array: verify pg_knoe_auth rejects this token type
-- Full end-to-end test requires the test harness to attempt a connection
-- with the appropriate token from mock_jwks.py /token/wrong/aud/array
-- and verify the connection is rejected with an authentication error.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'wrong_aud_array' AS test_case, 'requires_harness_connection_test' AS note;
test_case | note
--------------------------------------+---------------------------------
wrong_aud_array | requires_harness_connection_test
(1 row)

View File

@ -0,0 +1,11 @@
-- wrong_aud_string: verify pg_knoe_auth rejects this token type
-- Full end-to-end test requires the test harness to attempt a connection
-- with the appropriate token from mock_jwks.py /token/wrong/aud/string
-- and verify the connection is rejected with an authentication error.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'wrong_aud_string' AS test_case, 'requires_harness_connection_test' AS note;
test_case | note
--------------------------------------+---------------------------------
wrong_aud_string | requires_harness_connection_test
(1 row)

View File

@ -0,0 +1,11 @@
-- wrong_iss: verify pg_knoe_auth rejects this token type
-- Full end-to-end test requires the test harness to attempt a connection
-- with the appropriate token from mock_jwks.py /token/wrong/iss
-- and verify the connection is rejected with an authentication error.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'wrong_iss' AS test_case, 'requires_harness_connection_test' AS note;
test_case | note
--------------------------------------+---------------------------------
wrong_iss | requires_harness_connection_test
(1 row)

View File

@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
mock_jwks.py minimal JWKS server for pg_knoe_auth integration tests.
Generates an RSA-2048 key pair on startup, serves:
GET /jwks.json {"keys":[{...RSA public key JWK...}]}
GET /token signed JWT for the test user
GET /token/expired JWT with exp in the past
GET /token/wrong_iss JWT with wrong issuer
GET /token/wrong_aud JWT with wrong audience
GET /token/tampered JWT with last byte of signature flipped
GET /token/unknown_kid JWT with kid not in JWKS
POST /control/down stop serving JWKS (simulate unreachable issuer)
POST /control/up resume serving JWKS
Usage:
python3 mock_jwks.py [--port 9999]
The server writes the RSA private key and kid to stdout on startup so
the test harness can generate tokens independently if needed.
See docs/plans/junie/pg_knoe_auth-rename-harden-modularize.md §2.5
"""
import argparse
import base64
import json
import struct
import time
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
try:
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.backends import default_backend
except ImportError:
raise SystemExit("pip install cryptography")
# ── Key generation ────────────────────────────────────────────────────────────
PRIVATE_KEY = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend(),
)
PUBLIC_KEY = PRIVATE_KEY.public_key()
KID = "test-key-001"
ISSUER = "http://localhost:9999"
AUDIENCE = "pg.0.knoe.dev"
TEST_USER = "chrisfu"
_jwks_down = False # controlled via /control/down and /control/up
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def _int_to_b64url(n: int) -> str:
length = (n.bit_length() + 7) // 8
return _b64url(n.to_bytes(length, "big"))
def _make_jwks() -> dict:
pub = PUBLIC_KEY.public_numbers()
return {
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": KID,
"n": _int_to_b64url(pub.n),
"e": _int_to_b64url(pub.e),
}
]
}
def _make_jwt(claims: dict, kid: str = KID, tamper: bool = False) -> str:
header = {"alg": "RS256", "typ": "JWT", "kid": kid}
h = _b64url(json.dumps(header, separators=(",", ":")).encode())
p = _b64url(json.dumps(claims, separators=(",", ":")).encode())
signing_input = f"{h}.{p}".encode()
sig_bytes = PRIVATE_KEY.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
if tamper:
sig_list = bytearray(sig_bytes)
sig_list[-1] ^= 0xFF
sig_bytes = bytes(sig_list)
return f"{h}.{p}.{_b64url(sig_bytes)}"
def _base_claims(extra: dict = None) -> dict:
now = int(time.time())
claims = {
"iss": ISSUER,
"aud": AUDIENCE,
"sub": TEST_USER,
"preferred_username": TEST_USER,
"iat": now,
"exp": now + 3600,
}
if extra:
claims.update(extra)
return claims
# Pre-generate tokens
TOKENS = {
"valid": _make_jwt(_base_claims()),
"expired": _make_jwt(_base_claims({"exp": int(time.time()) - 10})),
"wrong_iss": _make_jwt(_base_claims({"iss": "https://evil.example.com"})),
"wrong_aud_string": _make_jwt(_base_claims({"aud": "other.service"})),
"wrong_aud_array": _make_jwt(_base_claims({"aud": ["other.service"]})),
"right_aud_array": _make_jwt(_base_claims({"aud": [AUDIENCE]})),
"tampered": _make_jwt(_base_claims(), tamper=True),
"unknown_kid": _make_jwt(_base_claims(), kid="no-such-kid"),
}
JWKS_JSON = json.dumps(_make_jwks()).encode()
# ── HTTP handler ──────────────────────────────────────────────────────────────
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass # suppress default access log
def _send(self, code: int, body: bytes, content_type: str = "application/json"):
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
global _jwks_down
if self.path == "/jwks.json" or self.path == "/.well-known/jwks.json":
if _jwks_down:
self._send(503, b'{"error":"simulated outage"}')
else:
self._send(200, JWKS_JSON)
elif self.path.startswith("/token"):
key = self.path.split("/token")[-1].lstrip("/") or "valid"
token = TOKENS.get(key)
if token:
self._send(200, token.encode(), "text/plain")
else:
self._send(404, b"unknown token type")
else:
self._send(404, b"not found")
def do_POST(self):
global _jwks_down
if self.path == "/control/down":
_jwks_down = True
self._send(200, b'{"status":"down"}')
elif self.path == "/control/up":
_jwks_down = False
self._send(200, b'{"status":"up"}')
else:
self._send(404, b"not found")
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=9999)
args = parser.parse_args()
server = HTTPServer(("127.0.0.1", args.port), Handler)
print(f"mock_jwks: listening on http://127.0.0.1:{args.port}", flush=True)
print(f"mock_jwks: kid={KID}", flush=True)
print(f"mock_jwks: issuer={ISSUER}", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass

View File

@ -0,0 +1,6 @@
-- expired_token: verify pg_knoe_auth rejects this token type
-- Full end-to-end test requires the test harness to attempt a connection
-- with the appropriate token from mock_jwks.py /token/expired/token
-- and verify the connection is rejected with an authentication error.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'expired_token' AS test_case, 'requires_harness_connection_test' AS note;

View File

@ -0,0 +1,6 @@
-- jwks_unreachable_after_cache: verify pg_knoe_auth rejects this token type
-- Full end-to-end test requires the test harness to attempt a connection
-- with the appropriate token from mock_jwks.py /token/jwks/unreachable/after/cache
-- and verify the connection is rejected with an authentication error.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'jwks_unreachable_after_cache' AS test_case, 'requires_harness_connection_test' AS note;

View File

@ -0,0 +1,6 @@
-- jwks_unreachable_first_call: verify pg_knoe_auth rejects this token type
-- Full end-to-end test requires the test harness to attempt a connection
-- with the appropriate token from mock_jwks.py /token/jwks/unreachable/first/call
-- and verify the connection is rejected with an authentication error.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'jwks_unreachable_first_call' AS test_case, 'requires_harness_connection_test' AS note;

View File

@ -0,0 +1,6 @@
-- kid_unknown: verify pg_knoe_auth rejects this token type
-- Full end-to-end test requires the test harness to attempt a connection
-- with the appropriate token from mock_jwks.py /token/kid/unknown
-- and verify the connection is rejected with an authentication error.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'kid_unknown' AS test_case, 'requires_harness_connection_test' AS note;

View File

@ -0,0 +1,15 @@
-- right_aud_array: verify pg_knoe_auth accepts a token with aud as a JSON array
-- containing the expected audience value.
--
-- This is the positive counterpart to wrong_aud_array.sql.
-- The token is issued by mock_jwks.py /token/right_aud_array with
-- "aud": ["pg.0.knoe.dev"] — the array form of the correct audience.
--
-- This test catches the §2.2.4-FIX bug: before the fix, json_get_string
-- returned NULL for array-form aud, causing valid tokens to be rejected.
--
-- Full end-to-end test requires the test harness to attempt a connection
-- with the token from mock_jwks.py /token/right_aud_array and verify
-- the connection is accepted.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'right_aud_array' AS test_case, 'requires_harness_connection_test' AS note;

View File

@ -0,0 +1,6 @@
-- tampered_sig: verify pg_knoe_auth rejects this token type
-- Full end-to-end test requires the test harness to attempt a connection
-- with the appropriate token from mock_jwks.py /token/tampered/sig
-- and verify the connection is rejected with an authentication error.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'tampered_sig' AS test_case, 'requires_harness_connection_test' AS note;

View File

@ -0,0 +1,12 @@
-- valid_token: properly signed JWT authenticates to correct role
-- Requires mock_jwks.py running and pg_knoe_auth loaded.
-- The \getenv + \connect with token is handled by the test harness wrapper;
-- here we verify the extension is loaded and the GUC is set.
SHOW shared_preload_libraries;
SELECT current_setting('pg_knoe_auth.role_claim');
SELECT current_setting('pg_knoe_auth.audience');
-- Verify the module is present in pg_preload_libraries
SELECT count(*) > 0 AS pg_knoe_auth_loaded
FROM pg_catalog.pg_file_settings
WHERE name = 'shared_preload_libraries'
AND setting LIKE '%pg_knoe_auth%';

View File

@ -0,0 +1,6 @@
-- wrong_aud_array: verify pg_knoe_auth rejects this token type
-- Full end-to-end test requires the test harness to attempt a connection
-- with the appropriate token from mock_jwks.py /token/wrong/aud/array
-- and verify the connection is rejected with an authentication error.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'wrong_aud_array' AS test_case, 'requires_harness_connection_test' AS note;

View File

@ -0,0 +1,6 @@
-- wrong_aud_string: verify pg_knoe_auth rejects this token type
-- Full end-to-end test requires the test harness to attempt a connection
-- with the appropriate token from mock_jwks.py /token/wrong/aud/string
-- and verify the connection is rejected with an authentication error.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'wrong_aud_string' AS test_case, 'requires_harness_connection_test' AS note;

View File

@ -0,0 +1,6 @@
-- wrong_iss: verify pg_knoe_auth rejects this token type
-- Full end-to-end test requires the test harness to attempt a connection
-- with the appropriate token from mock_jwks.py /token/wrong/iss
-- and verify the connection is rejected with an authentication error.
-- This SQL file validates the server-side GUC state is correct.
SELECT 'wrong_iss' AS test_case, 'requires_harness_connection_test' AS note;

View File

@ -0,0 +1,55 @@
# Makefile for pg_knoe_auth unit tests
# Run with: make check
# No PG cluster required — tests link only against OpenSSL.
CC = gcc
CFLAGS = -std=c11 -Wall -Wextra -g \
$(shell pkg-config --cflags openssl 2>/dev/null)
LDFLAGS = $(shell pkg-config --libs openssl 2>/dev/null)
TESTS = test_alg_validation \
test_aud_check \
test_aud_pipeline \
test_jwks_cache \
test_response_bound \
test_base64url
all: $(TESTS)
test_alg_validation: test_alg_validation.c test_helpers.h
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
test_aud_check: test_aud_check.c test_helpers.h
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
test_aud_pipeline: test_aud_pipeline.c test_helpers.h
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
test_jwks_cache: test_jwks_cache.c test_helpers.h
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
test_response_bound: test_response_bound.c test_helpers.h
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
test_base64url: test_base64url.c test_helpers.h
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
check: all
@echo "=== pg_knoe_auth unit tests ==="
@failed=0; \
for t in $(TESTS); do \
echo "--- $$t ---"; \
./$$t || failed=$$((failed + 1)); \
done; \
echo ""; \
if [ $$failed -eq 0 ]; then \
echo "All unit tests passed."; \
else \
echo "$$failed test(s) FAILED."; \
exit 1; \
fi
clean:
rm -f $(TESTS)
.PHONY: all check clean

View File

@ -0,0 +1,66 @@
/*
* test_alg_validation.c unit tests for H1: JWT alg header validation.
*
* Tests that only RS256 is accepted; alg=none, alg=HS256, missing alg all fail.
* See docs/plans/junie/pg_knoe_auth-rename-harden-modularize.md §2.2.1
*/
#include "test_helpers.h"
/*
* Inline the alg-check logic from pg_knoe_auth.c so we can test it without
* linking against PostgreSQL. The logic is: extract "alg" from a JSON
* header string and reject anything that isn't "RS256".
*/
/* Minimal json_get_string (same logic as production code) */
static char *
json_get_string(const char *json, const char *key)
{
char search[256];
const char *p, *start, *end;
size_t len;
snprintf(search, sizeof(search), "\"%s\"", key);
p = strstr(json, search);
if (!p) return NULL;
p += strlen(search);
while (*p == ' ' || *p == '\t') p++;
if (*p != ':') return NULL;
p++;
while (*p == ' ' || *p == '\t') p++;
if (*p != '"') return NULL;
p++;
start = p;
while (*p && *p != '"') { if (*p == '\\') p++; if (*p) p++; }
end = p;
len = (size_t)(end - start);
return pnstrdup(start, len);
}
/* Returns true if alg is valid (RS256), false otherwise — mirrors H1 logic */
static bool
alg_is_valid(const char *header_json)
{
char *alg = json_get_string(header_json, "alg");
bool ok = alg && strcmp(alg, "RS256") == 0;
if (alg) pfree(alg);
return ok;
}
int main(void)
{
ASSERT(!alg_is_valid("{\"alg\":\"none\",\"typ\":\"JWT\"}"),
"alg=none is rejected");
ASSERT(!alg_is_valid("{\"alg\":\"HS256\",\"typ\":\"JWT\"}"),
"alg=HS256 is rejected");
ASSERT(!alg_is_valid("{\"alg\":\"RS512\",\"typ\":\"JWT\"}"),
"alg=RS512 is rejected");
ASSERT(!alg_is_valid("{\"typ\":\"JWT\"}"),
"missing alg is rejected");
ASSERT( alg_is_valid("{\"alg\":\"RS256\",\"typ\":\"JWT\"}"),
"alg=RS256 is accepted");
ASSERT( alg_is_valid("{\"kid\":\"k1\",\"alg\":\"RS256\",\"typ\":\"JWT\"}"),
"alg=RS256 with kid is accepted");
TEST_SUMMARY();
}

View File

@ -0,0 +1,93 @@
/*
* test_aud_check.c unit tests for M1: aud claim exact match + array form.
*
* Tests string exact match, substring rejection, array form pass/fail.
* See docs/plans/junie/pg_knoe_auth-rename-harden-modularize.md §2.2.4
*/
#include "test_helpers.h"
/*
* aud_contains copied verbatim from pg_knoe_auth.c so we can test it
* without linking against PostgreSQL.
*/
static bool
aud_contains(const char *aud_value, const char *expected)
{
if (!aud_value || !expected)
return false;
/* Array form: ["val1","val2",...] */
if (aud_value[0] == '[') {
const char *p = aud_value + 1;
while (*p) {
const char *elem_start, *elem_end;
size_t elem_len;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',')
p++;
if (*p == ']' || *p == '\0')
break;
if (*p != '"')
break;
p++;
elem_start = p;
while (*p && *p != '"') {
if (*p == '\\') p++;
if (*p) p++;
}
elem_end = p;
if (*p == '"') p++;
elem_len = (size_t)(elem_end - elem_start);
if (elem_len == strlen(expected) &&
strncmp(elem_start, expected, elem_len) == 0)
return true;
}
return false;
}
/* String form: exact equality */
return strcmp(aud_value, expected) == 0;
}
int main(void)
{
/* String form — exact match */
ASSERT( aud_contains("pg.0.knoe.dev", "pg.0.knoe.dev"),
"string exact match passes");
/* String form — substring must NOT pass (old strstr bug) */
ASSERT(!aud_contains("evil.pg.0.knoe.dev", "pg.0.knoe.dev"),
"substring of expected in aud string is rejected");
ASSERT(!aud_contains("pg.0.knoe.dev.evil", "pg.0.knoe.dev"),
"expected as prefix of aud string is rejected");
/* String form — wrong value */
ASSERT(!aud_contains("other.service", "pg.0.knoe.dev"),
"wrong string aud is rejected");
/* Array form — single element match */
ASSERT( aud_contains("[\"pg.0.knoe.dev\"]", "pg.0.knoe.dev"),
"array with matching element passes");
/* Array form — multiple elements, one matches */
ASSERT( aud_contains("[\"other\",\"pg.0.knoe.dev\",\"more\"]", "pg.0.knoe.dev"),
"array with matching element among others passes");
/* Array form — no element matches */
ASSERT(!aud_contains("[\"other\",\"service\"]", "pg.0.knoe.dev"),
"array without matching element is rejected");
/* Array form — substring element must NOT match */
ASSERT(!aud_contains("[\"evil.pg.0.knoe.dev\"]", "pg.0.knoe.dev"),
"array element that is superset of expected is rejected");
/* NULL inputs */
ASSERT(!aud_contains(NULL, "pg.0.knoe.dev"),
"NULL aud_value is rejected");
ASSERT(!aud_contains("pg.0.knoe.dev", NULL),
"NULL expected is rejected");
TEST_SUMMARY();
}

View File

@ -0,0 +1,175 @@
/*
* test_aud_pipeline.c unit tests for the full aud claim pipeline.
*
* Tests the complete payload_json json_get_aud aud_contains path.
* This test MUST FAIL against rev1 code (before §2.2.4-FIX) because
* json_get_string bails on '[' and the array branch of aud_contains
* is never reached. It MUST PASS after §2.2.4-FIX (json_get_aud).
*
* 8 cases per §2.2.4-FIX table.
* See docs/plans/junie/pg_knoe_auth-rename-harden-modularize.md §2.2.4-FIX
*/
#include "test_helpers.h"
/* ── Inline copies of the functions under test ───────────────────────────── */
/*
* json_get_aud copied verbatim from pg_knoe_auth.c so we can test it
* without linking against PostgreSQL.
*/
static char *
json_get_aud(const char *json)
{
char search[16];
const char *p, *start;
int depth;
snprintf(search, sizeof(search), "\"aud\"");
p = strstr(json, search);
if (!p) return NULL;
p += strlen(search);
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != ':') return NULL;
p++;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p == '"') {
/* String form — return content between quotes */
p++;
start = p;
while (*p && *p != '"') {
if (*p == '\\') p++;
if (*p) p++;
}
return pnstrdup(start, (size_t)(p - start));
}
if (*p == '[') {
/* Array form — return [...] verbatim including brackets */
start = p;
depth = 1;
p++;
while (*p && depth > 0) {
if (*p == '"') {
p++;
while (*p && *p != '"') {
if (*p == '\\') p++;
if (*p) p++;
}
if (*p == '"') p++;
} else if (*p == '[') {
depth++;
p++;
} else if (*p == ']') {
depth--;
p++;
} else {
p++;
}
}
return pnstrdup(start, (size_t)(p - start));
}
/* Unknown shape (number, null, etc.) — caller treats as missing. */
return NULL;
}
/*
* aud_contains copied verbatim from pg_knoe_auth.c.
*/
static bool
aud_contains(const char *aud_value, const char *expected)
{
if (!aud_value || !expected)
return false;
/* Array form: ["val1","val2",...] */
if (aud_value[0] == '[') {
const char *p = aud_value + 1;
while (*p) {
const char *elem_start, *elem_end;
size_t elem_len;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',')
p++;
if (*p == ']' || *p == '\0')
break;
if (*p != '"')
break;
p++;
elem_start = p;
while (*p && *p != '"') {
if (*p == '\\') p++;
if (*p) p++;
}
elem_end = p;
if (*p == '"') p++;
elem_len = (size_t)(elem_end - elem_start);
if (elem_len == strlen(expected) &&
strncmp(elem_start, expected, elem_len) == 0)
return true;
}
return false;
}
/* String form: exact equality */
return strcmp(aud_value, expected) == 0;
}
/* ── Helper: run the full pipeline ──────────────────────────────────────── */
static bool
pipeline(const char *payload_json, const char *expected_aud)
{
char *claim_aud = json_get_aud(payload_json);
bool result = aud_contains(claim_aud, expected_aud);
if (claim_aud) pfree(claim_aud);
return result;
}
int main(void)
{
/* Case 1: string form — exact match → accept */
ASSERT( pipeline("{\"iss\":\"https://auth.0.knoe.dev\",\"aud\":\"pg.0.knoe.dev\",\"sub\":\"u1\"}",
"pg.0.knoe.dev"),
"string aud exact match passes");
/* Case 2: array form, single element matching → accept (THE KEY BUG CASE) */
ASSERT( pipeline("{\"iss\":\"https://auth.0.knoe.dev\",\"aud\":[\"pg.0.knoe.dev\"],\"sub\":\"u1\"}",
"pg.0.knoe.dev"),
"array aud single element match passes");
/* Case 3: array form, multiple elements, one matches → accept */
ASSERT( pipeline("{\"aud\":[\"other\",\"pg.0.knoe.dev\",\"more\"]}",
"pg.0.knoe.dev"),
"array aud multi-element match passes");
/* Case 4: array form, no element matches → reject */
ASSERT(!pipeline("{\"aud\":[\"other\",\"also-other\"]}",
"pg.0.knoe.dev"),
"array aud no match is rejected");
/* Case 5: string form — substring bypass attempt → reject */
ASSERT(!pipeline("{\"aud\":\"evil.pg.0.knoe.dev\"}",
"pg.0.knoe.dev"),
"string aud substring bypass is rejected");
/* Case 6: array form — substring bypass attempt → reject */
ASSERT(!pipeline("{\"aud\":[\"evil.pg.0.knoe.dev\"]}",
"pg.0.knoe.dev"),
"array aud substring bypass is rejected");
/* Case 7: numeric aud (e.g. aud:42) — json_get_aud returns NULL → reject */
ASSERT(!pipeline("{\"aud\":42,\"sub\":\"u1\"}",
"pg.0.knoe.dev"),
"numeric aud is rejected");
/* Case 8: missing aud claim entirely → reject */
ASSERT(!pipeline("{\"iss\":\"https://auth.0.knoe.dev\",\"sub\":\"u1\",\"exp\":9999999999}",
"pg.0.knoe.dev"),
"missing aud claim is rejected");
TEST_SUMMARY();
}

View File

@ -0,0 +1,113 @@
/*
* test_base64url.c unit tests for base64url decode (RFC 4648 §5 vectors).
*
* Tests correct decoding, missing padding handling, and bad input rejection.
* See docs/plans/junie/pg_knoe_auth-rename-harden-modularize.md §2.5
*/
#include "test_helpers.h"
#include <openssl/bio.h>
#include <openssl/evp.h>
/*
* base64url_decode copied verbatim from pg_knoe_auth.c.
*/
static unsigned char *
base64url_decode(const char *input, size_t input_len, size_t *out_len)
{
char *b64;
size_t b64_len;
size_t pad;
unsigned char *result;
int decoded_len;
BIO *bio, *b64bio;
b64_len = input_len;
pad = (4 - (input_len % 4)) % 4;
b64 = palloc(b64_len + pad + 2);
memcpy(b64, input, input_len);
for (size_t i = 0; i < input_len; i++) {
if (b64[i] == '-') b64[i] = '+';
else if (b64[i] == '_') b64[i] = '/';
}
for (size_t i = 0; i < pad; i++)
b64[b64_len + i] = '=';
b64[b64_len + pad] = '\n';
b64[b64_len + pad + 1] = '\0';
result = palloc(b64_len + pad + 4);
b64bio = BIO_new(BIO_f_base64());
bio = BIO_new_mem_buf(b64, (int)(b64_len + pad + 1));
bio = BIO_push(b64bio, bio);
BIO_set_flags(bio, BIO_FLAGS_BASE64_NO_NL);
decoded_len = BIO_read(bio, result, (int)(b64_len + pad + 4));
BIO_free_all(bio);
pfree(b64);
if (decoded_len < 0) {
pfree(result);
return NULL;
}
*out_len = (size_t) decoded_len;
return result;
}
int main(void)
{
unsigned char *out;
size_t out_len;
/*
* RFC 4648 §10 test vectors (base64url form '-' for '+', '_' for '/').
* Standard base64: "" ""
* "f" "Zg=="
* "fo" "Zm8="
* "foo" "Zm9v"
* "foob" "Zm9vYg=="
* "fooba" "Zm9vYmE="
* "foobar" "Zm9vYmFy"
* base64url strips padding.
*/
/* "f" → base64url "Zg" (no padding) */
out = base64url_decode("Zg", 2, &out_len);
ASSERT(out != NULL && out_len == 1 && out[0] == 'f',
"RFC4648 vector: 'Zg' decodes to 'f'");
if (out) pfree(out);
/* "fo" → base64url "Zm8" */
out = base64url_decode("Zm8", 3, &out_len);
ASSERT(out != NULL && out_len == 2 &&
out[0] == 'f' && out[1] == 'o',
"RFC4648 vector: 'Zm8' decodes to 'fo'");
if (out) pfree(out);
/* "foo" → base64url "Zm9v" */
out = base64url_decode("Zm9v", 4, &out_len);
ASSERT(out != NULL && out_len == 3 &&
memcmp(out, "foo", 3) == 0,
"RFC4648 vector: 'Zm9v' decodes to 'foo'");
if (out) pfree(out);
/* "foobar" → base64url "Zm9vYmFy" */
out = base64url_decode("Zm9vYmFy", 8, &out_len);
ASSERT(out != NULL && out_len == 6 &&
memcmp(out, "foobar", 6) == 0,
"RFC4648 vector: 'Zm9vYmFy' decodes to 'foobar'");
if (out) pfree(out);
/* base64url '-' and '_' substitution: encode "\xfb\xff" = "+/8=" in std,
* "-_8" in base64url (no pad) */
out = base64url_decode("-_8", 3, &out_len);
ASSERT(out != NULL && out_len == 2 &&
(unsigned char)out[0] == 0xfb && (unsigned char)out[1] == 0xff,
"base64url '-' and '_' chars decoded correctly");
if (out) pfree(out);
/* Empty input */
out = base64url_decode("", 0, &out_len);
ASSERT(out != NULL && out_len == 0,
"empty input decodes to empty output");
if (out) pfree(out);
TEST_SUMMARY();
}

View File

@ -0,0 +1,71 @@
/*
* test_helpers.h minimal test harness for pg_knoe_auth unit tests.
*
* Provides palloc/pfree/pstrdup/pnstrdup stubs (no PG cluster needed),
* a simple PASS/FAIL assertion macro, and a test-runner entry point.
*
* See docs/plans/junie/pg_knoe_auth-rename-harden-modularize.md §2.5
*/
#ifndef TEST_HELPERS_H
#define TEST_HELPERS_H
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <stdarg.h>
/* ── PG memory stubs ─────────────────────────────────────────────────────── */
static inline void *palloc(size_t n) { return malloc(n); }
static inline void *repalloc(void *p, size_t n) { return realloc(p, n); }
static inline void pfree(void *p) { free(p); }
static inline char *pstrdup(const char *s) { return strdup(s); }
static inline char *pnstrdup(const char *s, size_t n) {
char *r = malloc(n + 1);
memcpy(r, s, n);
r[n] = '\0';
return r;
}
/* ── PG logging stubs ────────────────────────────────────────────────────── */
#define WARNING 0
#define LOG 1
static inline void elog(int level, const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
fprintf(stderr, "[%s] ", level == LOG ? "LOG" : "WARNING");
vfprintf(stderr, fmt, ap);
fprintf(stderr, "\n");
va_end(ap);
}
/* ── TopMemoryContext stub ───────────────────────────────────────────────── */
typedef void *MemoryContext;
static MemoryContext TopMemoryContext = NULL;
static inline MemoryContext MemoryContextSwitchTo(MemoryContext ctx) {
(void) ctx; return NULL;
}
static inline void *MemoryContextAlloc(MemoryContext ctx, size_t n) {
(void) ctx; return malloc(n);
}
/* ── Test runner ─────────────────────────────────────────────────────────── */
static int _tests_run = 0;
static int _tests_failed = 0;
#define ASSERT(cond, msg) do { \
_tests_run++; \
if (!(cond)) { \
fprintf(stderr, "FAIL [%s:%d] %s\n", __FILE__, __LINE__, msg); \
_tests_failed++; \
} else { \
fprintf(stdout, "PASS %s\n", msg); \
} \
} while (0)
#define TEST_SUMMARY() do { \
printf("\n%d/%d tests passed\n", _tests_run - _tests_failed, _tests_run); \
return _tests_failed ? 1 : 0; \
} while (0)
#endif /* TEST_HELPERS_H */

View File

@ -0,0 +1,167 @@
/*
* 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();
}

View File

@ -0,0 +1,103 @@
/*
* test_response_bound.c unit tests for H3: HTTP response size bound.
*
* Tests that the curl write callback aborts at MAX_JWKS_BYTES+1 and
* passes for smaller payloads.
* See docs/plans/junie/pg_knoe_auth-rename-harden-modularize.md §2.2.3
*/
#include "test_helpers.h"
#define MAX_JWKS_BYTES (10 * 1024 * 1024)
typedef struct {
char *data;
size_t len;
size_t alloc;
} CurlBuf;
/* curl_write_cb — same logic as pg_knoe_auth.c */
static size_t
curl_write_cb(void *ptr, size_t size, size_t nmemb, void *userdata)
{
CurlBuf *buf = (CurlBuf *) userdata;
size_t new_bytes = size * nmemb;
if (buf->len + new_bytes > MAX_JWKS_BYTES) {
elog(WARNING, "pg_knoe_auth: JWKS response > %d bytes; aborting",
MAX_JWKS_BYTES);
return 0;
}
if (buf->len + new_bytes + 1 > buf->alloc) {
buf->alloc = (buf->len + new_bytes + 1) * 2;
buf->data = repalloc(buf->data, buf->alloc);
}
memcpy(buf->data + buf->len, ptr, new_bytes);
buf->len += new_bytes;
buf->data[buf->len] = '\0';
return new_bytes;
}
int main(void)
{
CurlBuf buf;
size_t ret;
char *chunk;
/* ── Test 1: small payload passes ──────────────────────────────────── */
buf.alloc = 8192;
buf.len = 0;
buf.data = palloc(buf.alloc);
buf.data[0] = '\0';
chunk = palloc(1024);
memset(chunk, 'A', 1024);
ret = curl_write_cb(chunk, 1, 1024, &buf);
ASSERT(ret == 1024, "1 KB payload: write callback returns 1024");
ASSERT(buf.len == 1024, "1 KB payload: buf.len is 1024");
pfree(chunk);
pfree(buf.data);
/* ── Test 2: exactly MAX_JWKS_BYTES passes ──────────────────────────── */
buf.alloc = MAX_JWKS_BYTES + 16;
buf.len = 0;
buf.data = palloc(buf.alloc);
buf.data[0] = '\0';
/* Simulate buf already at MAX_JWKS_BYTES - 1, then write 1 byte */
buf.len = MAX_JWKS_BYTES - 1;
chunk = palloc(1);
chunk[0] = 'X';
ret = curl_write_cb(chunk, 1, 1, &buf);
ASSERT(ret == 1, "write of 1 byte when buf.len == MAX-1 passes");
pfree(chunk);
pfree(buf.data);
/* ── Test 3: MAX_JWKS_BYTES + 1 aborts ─────────────────────────────── */
buf.alloc = MAX_JWKS_BYTES + 16;
buf.len = MAX_JWKS_BYTES;
buf.data = palloc(buf.alloc);
buf.data[0] = '\0';
chunk = palloc(1);
chunk[0] = 'Y';
ret = curl_write_cb(chunk, 1, 1, &buf);
ASSERT(ret == 0, "write of 1 byte when buf.len == MAX aborts (returns 0)");
pfree(chunk);
pfree(buf.data);
/* ── Test 4: large chunk that would overflow aborts ─────────────────── */
buf.alloc = 8192;
buf.len = MAX_JWKS_BYTES - 100;
buf.data = palloc(buf.alloc);
buf.data[0] = '\0';
chunk = palloc(200);
memset(chunk, 'Z', 200);
ret = curl_write_cb(chunk, 1, 200, &buf);
ASSERT(ret == 0, "200-byte chunk that would exceed MAX aborts");
pfree(chunk);
pfree(buf.data);
TEST_SUMMARY();
}

1
pg-knoe-auth/version Normal file
View File

@ -0,0 +1 @@
0.1.0