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

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

965 lines
31 KiB
C

/*-------------------------------------------------------------------------
* 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;
}