prole/pg-knoe-auth/test/unit/test_aud_pipeline.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

176 lines
5.6 KiB
C

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