mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 09:24:35 +00:00
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:
parent
0e822ea976
commit
57886f9268
@ -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; \
|
||||
|
||||
44
pg-knoe-auth/Dockerfile
Normal file
44
pg-knoe-auth/Dockerfile
Normal 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
75
pg-knoe-auth/README.md
Normal 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 (H1–H3, M1–M2) 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
23
pg-knoe-auth/src/Makefile
Normal 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
|
||||
964
pg-knoe-auth/src/pg_knoe_auth.c
Normal file
964
pg-knoe-auth/src/pg_knoe_auth.c
Normal 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;
|
||||
}
|
||||
39
pg-knoe-auth/test/integration/Makefile
Normal file
39
pg-knoe-auth/test/integration/Makefile
Normal 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
|
||||
44
pg-knoe-auth/test/integration/README.md
Normal file
44
pg-knoe-auth/test/integration/README.md
Normal 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) |
|
||||
11
pg-knoe-auth/test/integration/expected/expired_token.out
Normal file
11
pg-knoe-auth/test/integration/expected/expired_token.out
Normal 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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
11
pg-knoe-auth/test/integration/expected/kid_unknown.out
Normal file
11
pg-knoe-auth/test/integration/expected/kid_unknown.out
Normal 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)
|
||||
|
||||
11
pg-knoe-auth/test/integration/expected/tampered_sig.out
Normal file
11
pg-knoe-auth/test/integration/expected/tampered_sig.out
Normal 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)
|
||||
|
||||
27
pg-knoe-auth/test/integration/expected/valid_token.out
Normal file
27
pg-knoe-auth/test/integration/expected/valid_token.out
Normal 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)
|
||||
|
||||
11
pg-knoe-auth/test/integration/expected/wrong_aud_array.out
Normal file
11
pg-knoe-auth/test/integration/expected/wrong_aud_array.out
Normal 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)
|
||||
|
||||
11
pg-knoe-auth/test/integration/expected/wrong_aud_string.out
Normal file
11
pg-knoe-auth/test/integration/expected/wrong_aud_string.out
Normal 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)
|
||||
|
||||
11
pg-knoe-auth/test/integration/expected/wrong_iss.out
Normal file
11
pg-knoe-auth/test/integration/expected/wrong_iss.out
Normal 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)
|
||||
|
||||
181
pg-knoe-auth/test/integration/mock_jwks.py
Normal file
181
pg-knoe-auth/test/integration/mock_jwks.py
Normal 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
|
||||
6
pg-knoe-auth/test/integration/sql/expired_token.sql
Normal file
6
pg-knoe-auth/test/integration/sql/expired_token.sql
Normal 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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
6
pg-knoe-auth/test/integration/sql/kid_unknown.sql
Normal file
6
pg-knoe-auth/test/integration/sql/kid_unknown.sql
Normal 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;
|
||||
15
pg-knoe-auth/test/integration/sql/right_aud_array.sql
Normal file
15
pg-knoe-auth/test/integration/sql/right_aud_array.sql
Normal 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;
|
||||
6
pg-knoe-auth/test/integration/sql/tampered_sig.sql
Normal file
6
pg-knoe-auth/test/integration/sql/tampered_sig.sql
Normal 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;
|
||||
12
pg-knoe-auth/test/integration/sql/valid_token.sql
Normal file
12
pg-knoe-auth/test/integration/sql/valid_token.sql
Normal 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%';
|
||||
6
pg-knoe-auth/test/integration/sql/wrong_aud_array.sql
Normal file
6
pg-knoe-auth/test/integration/sql/wrong_aud_array.sql
Normal 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;
|
||||
6
pg-knoe-auth/test/integration/sql/wrong_aud_string.sql
Normal file
6
pg-knoe-auth/test/integration/sql/wrong_aud_string.sql
Normal 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;
|
||||
6
pg-knoe-auth/test/integration/sql/wrong_iss.sql
Normal file
6
pg-knoe-auth/test/integration/sql/wrong_iss.sql
Normal 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;
|
||||
55
pg-knoe-auth/test/unit/Makefile
Normal file
55
pg-knoe-auth/test/unit/Makefile
Normal 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
|
||||
66
pg-knoe-auth/test/unit/test_alg_validation.c
Normal file
66
pg-knoe-auth/test/unit/test_alg_validation.c
Normal 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();
|
||||
}
|
||||
93
pg-knoe-auth/test/unit/test_aud_check.c
Normal file
93
pg-knoe-auth/test/unit/test_aud_check.c
Normal 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();
|
||||
}
|
||||
175
pg-knoe-auth/test/unit/test_aud_pipeline.c
Normal file
175
pg-knoe-auth/test/unit/test_aud_pipeline.c
Normal 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();
|
||||
}
|
||||
113
pg-knoe-auth/test/unit/test_base64url.c
Normal file
113
pg-knoe-auth/test/unit/test_base64url.c
Normal 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();
|
||||
}
|
||||
71
pg-knoe-auth/test/unit/test_helpers.h
Normal file
71
pg-knoe-auth/test/unit/test_helpers.h
Normal 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 */
|
||||
167
pg-knoe-auth/test/unit/test_jwks_cache.c
Normal file
167
pg-knoe-auth/test/unit/test_jwks_cache.c
Normal 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();
|
||||
}
|
||||
103
pg-knoe-auth/test/unit/test_response_bound.c
Normal file
103
pg-knoe-auth/test/unit/test_response_bound.c
Normal 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
1
pg-knoe-auth/version
Normal file
@ -0,0 +1 @@
|
||||
0.1.0
|
||||
Loading…
Reference in New Issue
Block a user