From 57886f9268881209ff3f07e3aeb1c6b36f054e9e Mon Sep 17 00:00:00 2001 From: chrisfu Date: Sat, 23 May 2026 21:52:06 -0700 Subject: [PATCH] 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. --- knoe-db/Dockerfile | 12 + pg-knoe-auth/Dockerfile | 44 + pg-knoe-auth/README.md | 75 ++ pg-knoe-auth/src/Makefile | 23 + pg-knoe-auth/src/pg_knoe_auth.c | 964 ++++++++++++++++++ pg-knoe-auth/test/integration/Makefile | 39 + pg-knoe-auth/test/integration/README.md | 44 + .../integration/expected/expired_token.out | 11 + .../expected/jwks_unreachable_after_cache.out | 11 + .../expected/jwks_unreachable_first_call.out | 11 + .../test/integration/expected/kid_unknown.out | 11 + .../integration/expected/tampered_sig.out | 11 + .../test/integration/expected/valid_token.out | 27 + .../integration/expected/wrong_aud_array.out | 11 + .../integration/expected/wrong_aud_string.out | 11 + .../test/integration/expected/wrong_iss.out | 11 + pg-knoe-auth/test/integration/mock_jwks.py | 181 ++++ .../test/integration/sql/expired_token.sql | 6 + .../sql/jwks_unreachable_after_cache.sql | 6 + .../sql/jwks_unreachable_first_call.sql | 6 + .../test/integration/sql/kid_unknown.sql | 6 + .../test/integration/sql/right_aud_array.sql | 15 + .../test/integration/sql/tampered_sig.sql | 6 + .../test/integration/sql/valid_token.sql | 12 + .../test/integration/sql/wrong_aud_array.sql | 6 + .../test/integration/sql/wrong_aud_string.sql | 6 + .../test/integration/sql/wrong_iss.sql | 6 + pg-knoe-auth/test/unit/Makefile | 55 + pg-knoe-auth/test/unit/test_alg_validation.c | 66 ++ pg-knoe-auth/test/unit/test_aud_check.c | 93 ++ pg-knoe-auth/test/unit/test_aud_pipeline.c | 175 ++++ pg-knoe-auth/test/unit/test_base64url.c | 113 ++ pg-knoe-auth/test/unit/test_helpers.h | 71 ++ pg-knoe-auth/test/unit/test_jwks_cache.c | 167 +++ pg-knoe-auth/test/unit/test_response_bound.c | 103 ++ pg-knoe-auth/version | 1 + 36 files changed, 2416 insertions(+) create mode 100644 pg-knoe-auth/Dockerfile create mode 100644 pg-knoe-auth/README.md create mode 100644 pg-knoe-auth/src/Makefile create mode 100644 pg-knoe-auth/src/pg_knoe_auth.c create mode 100644 pg-knoe-auth/test/integration/Makefile create mode 100644 pg-knoe-auth/test/integration/README.md create mode 100644 pg-knoe-auth/test/integration/expected/expired_token.out create mode 100644 pg-knoe-auth/test/integration/expected/jwks_unreachable_after_cache.out create mode 100644 pg-knoe-auth/test/integration/expected/jwks_unreachable_first_call.out create mode 100644 pg-knoe-auth/test/integration/expected/kid_unknown.out create mode 100644 pg-knoe-auth/test/integration/expected/tampered_sig.out create mode 100644 pg-knoe-auth/test/integration/expected/valid_token.out create mode 100644 pg-knoe-auth/test/integration/expected/wrong_aud_array.out create mode 100644 pg-knoe-auth/test/integration/expected/wrong_aud_string.out create mode 100644 pg-knoe-auth/test/integration/expected/wrong_iss.out create mode 100644 pg-knoe-auth/test/integration/mock_jwks.py create mode 100644 pg-knoe-auth/test/integration/sql/expired_token.sql create mode 100644 pg-knoe-auth/test/integration/sql/jwks_unreachable_after_cache.sql create mode 100644 pg-knoe-auth/test/integration/sql/jwks_unreachable_first_call.sql create mode 100644 pg-knoe-auth/test/integration/sql/kid_unknown.sql create mode 100644 pg-knoe-auth/test/integration/sql/right_aud_array.sql create mode 100644 pg-knoe-auth/test/integration/sql/tampered_sig.sql create mode 100644 pg-knoe-auth/test/integration/sql/valid_token.sql create mode 100644 pg-knoe-auth/test/integration/sql/wrong_aud_array.sql create mode 100644 pg-knoe-auth/test/integration/sql/wrong_aud_string.sql create mode 100644 pg-knoe-auth/test/integration/sql/wrong_iss.sql create mode 100644 pg-knoe-auth/test/unit/Makefile create mode 100644 pg-knoe-auth/test/unit/test_alg_validation.c create mode 100644 pg-knoe-auth/test/unit/test_aud_check.c create mode 100644 pg-knoe-auth/test/unit/test_aud_pipeline.c create mode 100644 pg-knoe-auth/test/unit/test_base64url.c create mode 100644 pg-knoe-auth/test/unit/test_helpers.h create mode 100644 pg-knoe-auth/test/unit/test_jwks_cache.c create mode 100644 pg-knoe-auth/test/unit/test_response_bound.c create mode 100644 pg-knoe-auth/version diff --git a/knoe-db/Dockerfile b/knoe-db/Dockerfile index 59efe71..da6c60c 100644 --- a/knoe-db/Dockerfile +++ b/knoe-db/Dockerfile @@ -61,6 +61,7 @@ RUN set -eux; \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ cmake \ libssl-dev \ + libcurl4-openssl-dev \ libgdal-dev \ libproj-dev \ libgeos-dev \ @@ -160,6 +161,17 @@ RUN --mount=type=ssh set -eux; \ cd /; \ 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 RUN set -eux; \ echo "#!/bin/bash" > /docker-entrypoint-initdb.d/05_create_knoe_owner.sh; \ diff --git a/pg-knoe-auth/Dockerfile b/pg-knoe-auth/Dockerfile new file mode 100644 index 0000000..ebc9e9a --- /dev/null +++ b/pg-knoe-auth/Dockerfile @@ -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/ diff --git a/pg-knoe-auth/README.md b/pg-knoe-auth/README.md new file mode 100644 index 0000000..147a42c --- /dev/null +++ b/pg-knoe-auth/README.md @@ -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 `/.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 (H1–H3, M1–M2) applied in v0.1.0. + +## Version + +See `version` file. Tagged as `-` in the artifact registry. diff --git a/pg-knoe-auth/src/Makefile b/pg-knoe-auth/src/Makefile new file mode 100644 index 0000000..e3fda02 --- /dev/null +++ b/pg-knoe-auth/src/Makefile @@ -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 diff --git a/pg-knoe-auth/src/pg_knoe_auth.c b/pg-knoe-auth/src/pg_knoe_auth.c new file mode 100644 index 0000000..2eb530e --- /dev/null +++ b/pg-knoe-auth/src/pg_knoe_auth.c @@ -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 + * /.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 +#include +#include +#include +#include +#include +#include + +#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 = "." (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 "." 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; +} diff --git a/pg-knoe-auth/test/integration/Makefile b/pg-knoe-auth/test/integration/Makefile new file mode 100644 index 0000000..f7996ab --- /dev/null +++ b/pg-knoe-auth/test/integration/Makefile @@ -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 diff --git a/pg-knoe-auth/test/integration/README.md b/pg-knoe-auth/test/integration/README.md new file mode 100644 index 0000000..ded16b8 --- /dev/null +++ b/pg-knoe-auth/test/integration/README.md @@ -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) | diff --git a/pg-knoe-auth/test/integration/expected/expired_token.out b/pg-knoe-auth/test/integration/expected/expired_token.out new file mode 100644 index 0000000..8c2af91 --- /dev/null +++ b/pg-knoe-auth/test/integration/expected/expired_token.out @@ -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) + diff --git a/pg-knoe-auth/test/integration/expected/jwks_unreachable_after_cache.out b/pg-knoe-auth/test/integration/expected/jwks_unreachable_after_cache.out new file mode 100644 index 0000000..3ce6127 --- /dev/null +++ b/pg-knoe-auth/test/integration/expected/jwks_unreachable_after_cache.out @@ -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) + diff --git a/pg-knoe-auth/test/integration/expected/jwks_unreachable_first_call.out b/pg-knoe-auth/test/integration/expected/jwks_unreachable_first_call.out new file mode 100644 index 0000000..46ebe64 --- /dev/null +++ b/pg-knoe-auth/test/integration/expected/jwks_unreachable_first_call.out @@ -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) + diff --git a/pg-knoe-auth/test/integration/expected/kid_unknown.out b/pg-knoe-auth/test/integration/expected/kid_unknown.out new file mode 100644 index 0000000..af24b38 --- /dev/null +++ b/pg-knoe-auth/test/integration/expected/kid_unknown.out @@ -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) + diff --git a/pg-knoe-auth/test/integration/expected/tampered_sig.out b/pg-knoe-auth/test/integration/expected/tampered_sig.out new file mode 100644 index 0000000..a61fe6f --- /dev/null +++ b/pg-knoe-auth/test/integration/expected/tampered_sig.out @@ -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) + diff --git a/pg-knoe-auth/test/integration/expected/valid_token.out b/pg-knoe-auth/test/integration/expected/valid_token.out new file mode 100644 index 0000000..0db9738 --- /dev/null +++ b/pg-knoe-auth/test/integration/expected/valid_token.out @@ -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) + diff --git a/pg-knoe-auth/test/integration/expected/wrong_aud_array.out b/pg-knoe-auth/test/integration/expected/wrong_aud_array.out new file mode 100644 index 0000000..63acab9 --- /dev/null +++ b/pg-knoe-auth/test/integration/expected/wrong_aud_array.out @@ -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) + diff --git a/pg-knoe-auth/test/integration/expected/wrong_aud_string.out b/pg-knoe-auth/test/integration/expected/wrong_aud_string.out new file mode 100644 index 0000000..b197dad --- /dev/null +++ b/pg-knoe-auth/test/integration/expected/wrong_aud_string.out @@ -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) + diff --git a/pg-knoe-auth/test/integration/expected/wrong_iss.out b/pg-knoe-auth/test/integration/expected/wrong_iss.out new file mode 100644 index 0000000..30a3cd8 --- /dev/null +++ b/pg-knoe-auth/test/integration/expected/wrong_iss.out @@ -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) + diff --git a/pg-knoe-auth/test/integration/mock_jwks.py b/pg-knoe-auth/test/integration/mock_jwks.py new file mode 100644 index 0000000..6510f8a --- /dev/null +++ b/pg-knoe-auth/test/integration/mock_jwks.py @@ -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 diff --git a/pg-knoe-auth/test/integration/sql/expired_token.sql b/pg-knoe-auth/test/integration/sql/expired_token.sql new file mode 100644 index 0000000..375b96e --- /dev/null +++ b/pg-knoe-auth/test/integration/sql/expired_token.sql @@ -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; diff --git a/pg-knoe-auth/test/integration/sql/jwks_unreachable_after_cache.sql b/pg-knoe-auth/test/integration/sql/jwks_unreachable_after_cache.sql new file mode 100644 index 0000000..29134f3 --- /dev/null +++ b/pg-knoe-auth/test/integration/sql/jwks_unreachable_after_cache.sql @@ -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; diff --git a/pg-knoe-auth/test/integration/sql/jwks_unreachable_first_call.sql b/pg-knoe-auth/test/integration/sql/jwks_unreachable_first_call.sql new file mode 100644 index 0000000..6aecfab --- /dev/null +++ b/pg-knoe-auth/test/integration/sql/jwks_unreachable_first_call.sql @@ -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; diff --git a/pg-knoe-auth/test/integration/sql/kid_unknown.sql b/pg-knoe-auth/test/integration/sql/kid_unknown.sql new file mode 100644 index 0000000..6f67c6a --- /dev/null +++ b/pg-knoe-auth/test/integration/sql/kid_unknown.sql @@ -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; diff --git a/pg-knoe-auth/test/integration/sql/right_aud_array.sql b/pg-knoe-auth/test/integration/sql/right_aud_array.sql new file mode 100644 index 0000000..e3c6a9a --- /dev/null +++ b/pg-knoe-auth/test/integration/sql/right_aud_array.sql @@ -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; diff --git a/pg-knoe-auth/test/integration/sql/tampered_sig.sql b/pg-knoe-auth/test/integration/sql/tampered_sig.sql new file mode 100644 index 0000000..0574ff2 --- /dev/null +++ b/pg-knoe-auth/test/integration/sql/tampered_sig.sql @@ -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; diff --git a/pg-knoe-auth/test/integration/sql/valid_token.sql b/pg-knoe-auth/test/integration/sql/valid_token.sql new file mode 100644 index 0000000..c52c61b --- /dev/null +++ b/pg-knoe-auth/test/integration/sql/valid_token.sql @@ -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%'; diff --git a/pg-knoe-auth/test/integration/sql/wrong_aud_array.sql b/pg-knoe-auth/test/integration/sql/wrong_aud_array.sql new file mode 100644 index 0000000..66b0e76 --- /dev/null +++ b/pg-knoe-auth/test/integration/sql/wrong_aud_array.sql @@ -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; diff --git a/pg-knoe-auth/test/integration/sql/wrong_aud_string.sql b/pg-knoe-auth/test/integration/sql/wrong_aud_string.sql new file mode 100644 index 0000000..5d9cd45 --- /dev/null +++ b/pg-knoe-auth/test/integration/sql/wrong_aud_string.sql @@ -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; diff --git a/pg-knoe-auth/test/integration/sql/wrong_iss.sql b/pg-knoe-auth/test/integration/sql/wrong_iss.sql new file mode 100644 index 0000000..6d41fd0 --- /dev/null +++ b/pg-knoe-auth/test/integration/sql/wrong_iss.sql @@ -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; diff --git a/pg-knoe-auth/test/unit/Makefile b/pg-knoe-auth/test/unit/Makefile new file mode 100644 index 0000000..1951016 --- /dev/null +++ b/pg-knoe-auth/test/unit/Makefile @@ -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 diff --git a/pg-knoe-auth/test/unit/test_alg_validation.c b/pg-knoe-auth/test/unit/test_alg_validation.c new file mode 100644 index 0000000..a071a27 --- /dev/null +++ b/pg-knoe-auth/test/unit/test_alg_validation.c @@ -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(); +} diff --git a/pg-knoe-auth/test/unit/test_aud_check.c b/pg-knoe-auth/test/unit/test_aud_check.c new file mode 100644 index 0000000..0312bea --- /dev/null +++ b/pg-knoe-auth/test/unit/test_aud_check.c @@ -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(); +} diff --git a/pg-knoe-auth/test/unit/test_aud_pipeline.c b/pg-knoe-auth/test/unit/test_aud_pipeline.c new file mode 100644 index 0000000..ec70597 --- /dev/null +++ b/pg-knoe-auth/test/unit/test_aud_pipeline.c @@ -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(); +} diff --git a/pg-knoe-auth/test/unit/test_base64url.c b/pg-knoe-auth/test/unit/test_base64url.c new file mode 100644 index 0000000..9e3842e --- /dev/null +++ b/pg-knoe-auth/test/unit/test_base64url.c @@ -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 +#include + +/* + * 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(); +} diff --git a/pg-knoe-auth/test/unit/test_helpers.h b/pg-knoe-auth/test/unit/test_helpers.h new file mode 100644 index 0000000..abe37ee --- /dev/null +++ b/pg-knoe-auth/test/unit/test_helpers.h @@ -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 +#include +#include +#include +#include + +/* ── 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 */ diff --git a/pg-knoe-auth/test/unit/test_jwks_cache.c b/pg-knoe-auth/test/unit/test_jwks_cache.c new file mode 100644 index 0000000..4098524 --- /dev/null +++ b/pg-knoe-auth/test/unit/test_jwks_cache.c @@ -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 +#include +#include +#pragma GCC diagnostic pop +#include + +/* ── 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(); +} diff --git a/pg-knoe-auth/test/unit/test_response_bound.c b/pg-knoe-auth/test/unit/test_response_bound.c new file mode 100644 index 0000000..0fbf5d1 --- /dev/null +++ b/pg-knoe-auth/test/unit/test_response_bound.c @@ -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(); +} diff --git a/pg-knoe-auth/version b/pg-knoe-auth/version new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/pg-knoe-auth/version @@ -0,0 +1 @@ +0.1.0