mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 10:13:58 +00:00
- 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.
965 lines
31 KiB
C
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;
|
|
}
|