From b6974d5c8e2e3c2f407b7eb2b72256f8096df785 Mon Sep 17 00:00:00 2001 From: Jacob Champion Date: Wed, 3 Dec 2025 15:47:23 -0800 Subject: [PATCH v3 6/6] WIP: Introduce third-party OAuth flow plugins? This experimental commit promotes the pg_start_oauthbearer API to a public header (libpq-oauth.h) and adds a PGOAUTHMODULE environment variable that overrides the load path for the plugin, allowing users to provide their own. The libpq_oauth_init function is now optional, and will remain undocumented. (Modules that don't provide it are marked as user-defined.) This is a relatively small amount of implementation change, but unfortunately the tests have a large amount of code motion to be able to share logic between the test executable and plugin. I might need to split that into multiple squash! commits to make it more easily reviewable. TODO: figure out PGDLLEXPORT, which we do not currently provide publicly TODO: lock down PGOAUTHMODULE as necessary to avoid introducing exciting new vulnerabilities TODO: how hard would it be to support Windows here? Reviewed-by: Zsolt Parragi Discussion: https://postgr.es/m/CAOYmi%2BmrGg%2Bn_X2MOLgeWcj3v_M00gR8uz_D7mM8z%3DdX1JYVbg%40mail.gmail.com --- src/interfaces/libpq/meson.build | 1 + src/interfaces/libpq/Makefile | 2 + src/interfaces/libpq-oauth/oauth-curl.h | 24 -- src/interfaces/libpq/fe-auth-oauth.h | 2 +- src/interfaces/libpq/libpq-oauth.h | 52 +++ src/interfaces/libpq-oauth/oauth-curl.c | 2 +- src/interfaces/libpq/fe-auth-oauth.c | 121 ++++-- src/test/modules/oauth_validator/meson.build | 15 + src/test/modules/oauth_validator/Makefile | 10 +- .../oauth_validator/oauth_test_common.h | 26 ++ src/test/modules/oauth_validator/oauth_flow.c | 69 ++++ .../oauth_validator/oauth_hook_client.c | 319 +-------------- .../oauth_validator/oauth_test_common.c | 374 ++++++++++++++++++ .../modules/oauth_validator/t/002_client.pl | 56 ++- 14 files changed, 687 insertions(+), 386 deletions(-) delete mode 100644 src/interfaces/libpq-oauth/oauth-curl.h create mode 100644 src/interfaces/libpq/libpq-oauth.h create mode 100644 src/test/modules/oauth_validator/oauth_test_common.h create mode 100644 src/test/modules/oauth_validator/oauth_flow.c create mode 100644 src/test/modules/oauth_validator/oauth_test_common.c diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build index c5ecd9c3a87..caff7194eaf 100644 --- a/src/interfaces/libpq/meson.build +++ b/src/interfaces/libpq/meson.build @@ -128,6 +128,7 @@ pkgconfig.generate( install_headers( 'libpq-fe.h', 'libpq-events.h', + 'libpq-oauth.h', ) install_headers( diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile index bf4baa92917..d787f5a30df 100644 --- a/src/interfaces/libpq/Makefile +++ b/src/interfaces/libpq/Makefile @@ -155,6 +155,7 @@ $(top_builddir)/src/port/pg_config_paths.h: install: all installdirs install-lib $(INSTALL_DATA) $(srcdir)/libpq-fe.h '$(DESTDIR)$(includedir)' $(INSTALL_DATA) $(srcdir)/libpq-events.h '$(DESTDIR)$(includedir)' + $(INSTALL_DATA) $(srcdir)/libpq-oauth.h '$(DESTDIR)$(includedir)' $(INSTALL_DATA) $(srcdir)/libpq-int.h '$(DESTDIR)$(includedir_internal)' $(INSTALL_DATA) $(srcdir)/fe-auth-sasl.h '$(DESTDIR)$(includedir_internal)' $(INSTALL_DATA) $(srcdir)/pqexpbuffer.h '$(DESTDIR)$(includedir_internal)' @@ -177,6 +178,7 @@ installdirs: installdirs-lib uninstall: uninstall-lib rm -f '$(DESTDIR)$(includedir)/libpq-fe.h' rm -f '$(DESTDIR)$(includedir)/libpq-events.h' + rm -f '$(DESTDIR)$(includedir)/libpq-oauth.h' rm -f '$(DESTDIR)$(includedir_internal)/libpq-int.h' rm -f '$(DESTDIR)$(includedir_internal)/fe-auth-sasl.h' rm -f '$(DESTDIR)$(includedir_internal)/pqexpbuffer.h' diff --git a/src/interfaces/libpq-oauth/oauth-curl.h b/src/interfaces/libpq-oauth/oauth-curl.h deleted file mode 100644 index 1d4dd766217..00000000000 --- a/src/interfaces/libpq-oauth/oauth-curl.h +++ /dev/null @@ -1,24 +0,0 @@ -/*------------------------------------------------------------------------- - * - * oauth-curl.h - * - * Definitions for OAuth Device Authorization module - * - * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group - * Portions Copyright (c) 1994, Regents of the University of California - * - * src/interfaces/libpq-oauth/oauth-curl.h - * - *------------------------------------------------------------------------- - */ - -#ifndef OAUTH_CURL_H -#define OAUTH_CURL_H - -#include "libpq-fe.h" - -/* Exported flow callback. */ -extern PGDLLEXPORT int pg_start_oauthbearer(PGconn *conn, - PGoauthBearerRequestV2 *request); - -#endif /* OAUTH_CURL_H */ diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h index 511284614f7..5c95187f6d8 100644 --- a/src/interfaces/libpq/fe-auth-oauth.h +++ b/src/interfaces/libpq/fe-auth-oauth.h @@ -35,7 +35,7 @@ typedef struct void *async_ctx; bool builtin; - void *builtin_flow; + void *flow_module; } fe_oauth_state; extern void pqClearOAuthToken(PGconn *conn); diff --git a/src/interfaces/libpq/libpq-oauth.h b/src/interfaces/libpq/libpq-oauth.h new file mode 100644 index 00000000000..2a62b330b1c --- /dev/null +++ b/src/interfaces/libpq/libpq-oauth.h @@ -0,0 +1,52 @@ +/*------------------------------------------------------------------------- + * + * libpq-oauth.h + * This file contains structs and functions used by custom OAuth plugins. + * + * Copyright (c) 2025, PostgreSQL Global Development Group + * + * src/interfaces/libpq/libpq-oauth.h + * + *------------------------------------------------------------------------- + */ + +#ifndef LIBPQ_OAUTH_H +#define LIBPQ_OAUTH_H + +#include "libpq-fe.h" + +#ifdef __cplusplus +extern "C" +{ +#endif + +/* XXX can't rely on c.h, but duplicating this is asking for trouble */ +#ifndef PGDLLEXPORT +#ifdef _WIN32 +#define PGDLLEXPORT __declspec (dllexport) +#elif defined(__has_attribute) +#if __has_attribute(visibility) +#define PGDLLEXPORT __attribute__((visibility("default"))) +#else +#define PGDLLEXPORT +#endif +#else +#define PGDLLEXPORT +#endif +#endif + +/* + * V1 API + * + * Flow plugins must provide an implementation of this callback. + * + * TODO: provide a magic struct that allows backwards but not forwards compat? + */ +extern PGDLLEXPORT int pg_start_oauthbearer(PGconn *conn, + PGoauthBearerRequestV2 *request); + +#ifdef __cplusplus +} +#endif + +#endif /* LIBPQ_OAUTH_H */ diff --git a/src/interfaces/libpq-oauth/oauth-curl.c b/src/interfaces/libpq-oauth/oauth-curl.c index cebe0a24ad0..961ff8b49ac 100644 --- a/src/interfaces/libpq-oauth/oauth-curl.c +++ b/src/interfaces/libpq-oauth/oauth-curl.c @@ -29,8 +29,8 @@ #endif #include "common/jsonapi.h" +#include "libpq-oauth.h" #include "mb/pg_wchar.h" -#include "oauth-curl.h" #ifdef USE_DYNAMIC_OAUTH diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c index 904f43e90ea..ea0daddf4e8 100644 --- a/src/interfaces/libpq/fe-auth-oauth.c +++ b/src/interfaces/libpq/fe-auth-oauth.c @@ -17,6 +17,8 @@ #ifdef USE_DYNAMIC_OAUTH #include +#else +#include "libpq-oauth.h" #endif #include "common/base64.h" @@ -880,41 +882,67 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *re * On the other platforms, load the module using only the basename, to * rely on the runtime linker's standard search behavior. */ - const char *const module_name = + const char *module_name = #if defined(__darwin__) LIBDIR "/libpq-oauth" DLSUFFIX; #else "libpq-oauth" DLSUFFIX; #endif - state->builtin_flow = dlopen(module_name, RTLD_NOW | RTLD_LOCAL); - if (!state->builtin_flow) + /*- + * Additionally, the user may override the module path explicitly to be + * able to provide their own module, via PGOAUTHMODULE. + * + * TODO: have to think about _all_ the security ramifications of this. What + * existing protections in LD_LIBRARY_PATH (and/or SIP) are we potentially + * bypassing? Should we check the permissions of the file somehow...? + * TODO: maybe disallow anything not underneath LIBDIR? or PKGLIBDIR? + * Should it have a naming convention? + */ + const char *env = getenv("PGOAUTHMODULE"); + + if (env && env[0]) + module_name = env; + else + state->builtin = true; + + state->flow_module = dlopen(module_name, RTLD_NOW | RTLD_LOCAL); + if (!state->flow_module) { /* * For end users, this probably isn't an error condition, it just * means the flow isn't installed. Developers and package maintainers - * may want to debug this via the PGOAUTHDEBUG envvar, though. + * may want to debug this via the PGOAUTHDEBUG envvar, though, and we + * should be more noisy if users tried to provide a PGOAUTHMODULE. * * Note that POSIX dlerror() isn't guaranteed to be threadsafe. */ if (oauth_unsafe_debugging_enabled()) - fprintf(stderr, "failed dlopen for libpq-oauth: %s\n", dlerror()); + fprintf(stderr, "failed dlopen for %s: %s\n", module_name, dlerror()); + + if (state->builtin) + return 0; - return 0; + request->error = libpq_gettext("plugin could not be loaded"); + return -1; } - if ((init = dlsym(state->builtin_flow, "libpq_oauth_init")) == NULL - || (start_flow = dlsym(state->builtin_flow, "pg_start_oauthbearer")) == NULL) + if ((start_flow = dlsym(state->flow_module, "pg_start_oauthbearer")) == NULL) { /* * This is more of an error condition than the one above, but due to * the dlerror() threadsafety issue, lock it behind PGOAUTHDEBUG too. */ if (oauth_unsafe_debugging_enabled()) - fprintf(stderr, "failed dlsym for libpq-oauth: %s\n", dlerror()); + fprintf(stderr, "failed dlsym for %s: %s\n", module_name, dlerror()); + + dlclose(state->flow_module); + + if (state->builtin) + return 0; - dlclose(state->builtin_flow); - return 0; + request->error = libpq_gettext("plugin entry point could not be located"); + return -1; } /* @@ -923,34 +951,46 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *re */ /* - * We need to inject necessary function pointers into the module. This - * only needs to be done once -- even if the pointers are constant, - * assigning them while another thread is executing the flows feels like - * tempting fate. + * Our libpq-oauth.so provides a special initialization function for libpq + * integration. It's not a problem if we don't find this; it just means + * that a user-defined module is being used. */ - if ((lockerr = pthread_mutex_lock(&init_mutex)) != 0) + init = dlsym(state->flow_module, "libpq_oauth_init"); + + if (!init) + state->builtin = false; /* adjust our error messages */ + else { - /* Should not happen... but don't continue if it does. */ - Assert(false); + /* + * We need to inject necessary function pointers into the module. This + * only needs to be done once -- even if the pointers are constant, + * assigning them while another thread is executing the flows feels + * like tempting fate. + */ + if ((lockerr = pthread_mutex_lock(&init_mutex)) != 0) + { + /* Should not happen... but don't continue if it does. */ + Assert(false); - libpq_append_conn_error(conn, "failed to lock mutex (%d)", lockerr); - return 0; - } + libpq_append_conn_error(conn, "failed to lock mutex (%d)", lockerr); + return 0; + } - if (!initialized) - { - init( + if (!initialized) + { + init( #ifdef ENABLE_NLS - libpq_gettext + libpq_gettext #else - NULL + NULL #endif - ); + ); - initialized = true; - } + initialized = true; + } - pthread_mutex_unlock(&init_mutex); + pthread_mutex_unlock(&init_mutex); + } return (start_flow(conn, request) == 0) ? 1 : -1; } @@ -967,6 +1007,7 @@ extern int pg_start_oauthbearer(PGconn *conn, PGoauthBearerRequestV2 *request); static int use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *request) { + state->builtin = true; return (pg_start_oauthbearer(conn, request) == 0) ? 1 : -1; } @@ -1013,10 +1054,7 @@ setup_token_request(PGconn *conn, fe_oauth_state *state) if (res == 0) res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request); if (res == 0) - { - state->builtin = true; res = use_builtin_flow(conn, state, &request); - } if (res > 0) { @@ -1054,19 +1092,18 @@ setup_token_request(PGconn *conn, fe_oauth_state *state) conn->async_auth = run_oauth_flow; conn->cleanup_async_auth = cleanup_oauth_flow; state->async_ctx = request_copy; + + return true; } - else if (res < 0) - { + + /* + * Failure cases: either we tried to set up a flow and failed, or there + * was no flow to try. + */ + if (res < 0) report_flow_error(conn, &request); - goto fail; - } else - { libpq_append_conn_error(conn, "no OAuth flows are available (try installing the libpq-oauth package)"); - goto fail; - } - - return true; fail: if (request.v1.cleanup) diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build index c4b73e05297..71412f0ecab 100644 --- a/src/test/modules/oauth_validator/meson.build +++ b/src/test/modules/oauth_validator/meson.build @@ -50,6 +50,7 @@ test_install_libs += magic_validator oauth_hook_client_sources = files( 'oauth_hook_client.c', + 'oauth_test_common.c', ) if host_system == 'windows' @@ -67,6 +68,19 @@ oauth_hook_client = executable('oauth_hook_client', ) testprep_targets += oauth_hook_client +oauth_flow = shared_module('oauth_flow', + files( + 'oauth_flow.c', + 'oauth_test_common.c', + ), + include_directories: [postgres_inc], + dependencies: [frontend_shlib_code, libpq], + kwargs: default_lib_args + { + 'install': false, + }, +) +testprep_targets += oauth_flow + tests += { 'name': 'oauth_validator', 'sd': meson.current_source_dir(), @@ -80,6 +94,7 @@ tests += { 'PYTHON': python.full_path(), 'with_libcurl': oauth_flow_supported ? 'yes' : 'no', 'with_python': 'yes', + 'flow_module_path': oauth_flow.full_path(), }, 'deps': [oauth_hook_client], }, diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile index cb64f0f1437..a17c4259aea 100644 --- a/src/test/modules/oauth_validator/Makefile +++ b/src/test/modules/oauth_validator/Makefile @@ -14,11 +14,13 @@ PGFILEDESC = "validator - test OAuth validator module" PROGRAM = oauth_hook_client PGAPPICON = win32 -OBJS = $(WIN32RES) oauth_hook_client.o +OBJS = $(WIN32RES) oauth_hook_client.o oauth_test_common.o PG_CPPFLAGS = -I$(libpq_srcdir) PG_LIBS_INTERNAL += $(libpq_pgport) +EXTRA_CLEAN = oauth_flow$(DLSUFFIX) oauth_flow.o + NO_INSTALLCHECK = 1 TAP_TESTS = 1 @@ -33,8 +35,14 @@ top_builddir = ../../../.. include $(top_builddir)/src/Makefile.global include $(top_srcdir)/contrib/contrib-global.mk +all: oauth_flow$(DLSUFFIX) + +oauth_flow$(DLSUFFIX): oauth_flow.o oauth_test_common.o + $(CC) $(CFLAGS) $^ $(LDFLAGS) $(libpq_pgport_shlib) $(LDFLAGS_SL) -shared -o $@ + export PYTHON export with_libcurl export with_python +export flow_module_path := $(abs_top_builddir)/$(subdir)/oauth_flow$(DLSUFFIX) endif diff --git a/src/test/modules/oauth_validator/oauth_test_common.h b/src/test/modules/oauth_validator/oauth_test_common.h new file mode 100644 index 00000000000..33e72e30440 --- /dev/null +++ b/src/test/modules/oauth_validator/oauth_test_common.h @@ -0,0 +1,26 @@ +/*------------------------------------------------------------------------- + * + * oauth_test_common.h + * Shared functionality for oauth_hook_client and oauth_flow + * + * Copyright (c) 2025, PostgreSQL Global Development Group + * + *------------------------------------------------------------------------- + */ + +#ifndef OAUTH_TEST_COMMON_H +#define OAUTH_TEST_COMMON_H + +/* + * Only public headers can be here, since oauth_flow.c is trying to test only + * the public API. + */ +#include "libpq-fe.h" + +extern int stress_async; /* for oauth_hook_client */ + +extern char *oauth_test_parse_argv(int argc, char *argv[], int for_plugin); +extern int oauth_test_authdata_hook(PGauthData type, PGconn *conn, void *data); +extern int oauth_test_start_flow(PGconn *conn, PGoauthBearerRequestV2 *request); + +#endif /* OAUTH_TEST_COMMON_H */ diff --git a/src/test/modules/oauth_validator/oauth_flow.c b/src/test/modules/oauth_validator/oauth_flow.c new file mode 100644 index 00000000000..8068a45ae29 --- /dev/null +++ b/src/test/modules/oauth_validator/oauth_flow.c @@ -0,0 +1,69 @@ +/*------------------------------------------------------------------------- + * + * oauth_flow.c + * Test plugin for clientside OAuth flows + * + * Copyright (c) 2025, PostgreSQL Global Development Group + * + *------------------------------------------------------------------------- + */ + +#include +#include + +/* Since we want to test the public API, only include public headers here. */ +#include "libpq-fe.h" +#include "libpq-oauth.h" +#include "oauth_test_common.h" + +static void +load_test_flags(void) +{ + int argc; + char **argv; + char *env = getenv("OAUTH_TEST_FLAGS"); + int flag_count; + int i; + + if (!env || !env[0]) + { + fprintf(stderr, "OAUTH_TEST_FLAGS must be set\n"); + exit(1); + } + + flag_count = 1; + for (char *c = env; *c; c++) + { + if (*c == '\x01') + flag_count++; + } + + argc = flag_count + 1; + argv = malloc(sizeof(*argv) * (argc + 1)); + if (!argv) + { + fprintf(stderr, "out of memory"); + exit(1); + } + + argv[0] = "[plugin test]"; + for (i = 1; i < flag_count; i++) + { + argv[i] = env; + + env = strchr(env, '\x01'); + *env++ = '\0'; + } + argv[flag_count] = env; + argv[argc] = NULL; + + oauth_test_parse_argv(argc, argv, 1 /* plugin */ ); +} + +int +pg_start_oauthbearer(PGconn *conn, PGoauthBearerRequestV2 *request) +{ + load_test_flags(); + + return oauth_test_start_flow(conn, request); +} diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c index 5bddc0f807a..5f932acc571 100644 --- a/src/test/modules/oauth_validator/oauth_hook_client.c +++ b/src/test/modules/oauth_validator/oauth_hook_client.c @@ -18,144 +18,18 @@ #include -#include "getopt_long.h" #include "libpq-fe.h" -#include "pqexpbuffer.h" -static int handle_auth_data(PGauthData type, PGconn *conn, void *data); -static PostgresPollingStatusType async_cb(PGconn *conn, - PGoauthBearerRequest *req, - pgsocket *altsock); -static PostgresPollingStatusType misbehave_cb(PGconn *conn, - PGoauthBearerRequest *req, - pgsocket *altsock); - -static void -usage(char *argv[]) -{ - printf("usage: %s [flags] CONNINFO\n\n", argv[0]); - - printf("recognized flags:\n"); - printf(" -h, --help show this message\n"); - printf(" -v VERSION select the hook API version (default 2)\n"); - printf(" --expected-scope SCOPE fail if received scopes do not match SCOPE\n"); - printf(" --expected-uri URI fail if received configuration link does not match URI\n"); - printf(" --expected-issuer ISS fail if received issuer does not match ISS (v2 only)\n"); - printf(" --misbehave=MODE have the hook fail required postconditions\n" - " (MODEs: no-hook, fail-async, no-token, no-socket)\n"); - printf(" --no-hook don't install OAuth hooks\n"); - printf(" --hang-forever don't ever return a token (combine with connect_timeout)\n"); - printf(" --token TOKEN use the provided TOKEN value\n"); - printf(" --error ERRMSG fail instead, with the given ERRMSG (v2 only)\n"); - printf(" --stress-async busy-loop on PQconnectPoll rather than polling\n"); -} - -/* --options */ -static bool no_hook = false; -static bool hang_forever = false; -static bool stress_async = false; -static const char *expected_uri = NULL; -static const char *expected_issuer = NULL; -static const char *expected_scope = NULL; -static const char *misbehave_mode = NULL; -static char *token = NULL; -static char *errmsg = NULL; -static int hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN_V2; +#include "oauth_test_common.h" int main(int argc, char *argv[]) { - static const struct option long_options[] = { - {"help", no_argument, NULL, 'h'}, - - {"expected-scope", required_argument, NULL, 1000}, - {"expected-uri", required_argument, NULL, 1001}, - {"no-hook", no_argument, NULL, 1002}, - {"token", required_argument, NULL, 1003}, - {"hang-forever", no_argument, NULL, 1004}, - {"misbehave", required_argument, NULL, 1005}, - {"stress-async", no_argument, NULL, 1006}, - {"expected-issuer", required_argument, NULL, 1007}, - {"error", required_argument, NULL, 1008}, - {0} - }; - - const char *conninfo; + const char *conninfo = oauth_test_parse_argv(argc, argv, 0 /* hook */ ); PGconn *conn; - int c; - - while ((c = getopt_long(argc, argv, "hv:", long_options, NULL)) != -1) - { - switch (c) - { - case 'h': - usage(argv); - return 0; - - case 'v': - if (strcmp(optarg, "1") == 0) - hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN; - else if (strcmp(optarg, "2") == 0) - hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN_V2; - else - { - usage(argv); - return 1; - } - break; - - case 1000: /* --expected-scope */ - expected_scope = optarg; - break; - - case 1001: /* --expected-uri */ - expected_uri = optarg; - break; - - case 1002: /* --no-hook */ - no_hook = true; - break; - - case 1003: /* --token */ - token = optarg; - break; - - case 1004: /* --hang-forever */ - hang_forever = true; - break; - - case 1005: /* --misbehave */ - misbehave_mode = optarg; - break; - - case 1006: /* --stress-async */ - stress_async = true; - break; - - case 1007: /* --expected-issuer */ - expected_issuer = optarg; - break; - - case 1008: /* --error */ - errmsg = optarg; - break; - - default: - usage(argv); - return 1; - } - } - - if (argc != optind + 1) - { - usage(argv); - return 1; - } - - conninfo = argv[optind]; /* Set up our OAuth hooks. */ - PQsetAuthDataHook(handle_auth_data); + PQsetAuthDataHook(oauth_test_authdata_hook); /* Connect. (All the actual work is in the hook.) */ if (stress_async) @@ -193,190 +67,3 @@ main(int argc, char *argv[]) PQfinish(conn); return 0; } - -/* - * PQauthDataHook implementation. Replaces the default client flow by handling - * PQAUTHDATA_OAUTH_BEARER_TOKEN[_V2]. - */ -static int -handle_auth_data(PGauthData type, PGconn *conn, void *data) -{ - PGoauthBearerRequest *req; - PGoauthBearerRequestV2 *req2 = NULL; - - Assert(hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN || - hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN_V2); - - if (no_hook || type != hook_version) - return 0; - - req = data; - if (type == PQAUTHDATA_OAUTH_BEARER_TOKEN_V2) - req2 = data; - - if (hang_forever) - { - /* Start asynchronous processing. */ - req->async = async_cb; - return 1; - } - - if (misbehave_mode) - { - if (strcmp(misbehave_mode, "no-hook") != 0) - req->async = misbehave_cb; - return 1; - } - - if (expected_uri) - { - if (!req->openid_configuration) - { - fprintf(stderr, "expected URI \"%s\", got NULL\n", expected_uri); - return -1; - } - - if (strcmp(expected_uri, req->openid_configuration) != 0) - { - fprintf(stderr, "expected URI \"%s\", got \"%s\"\n", expected_uri, req->openid_configuration); - return -1; - } - } - - if (expected_scope) - { - if (!req->scope) - { - fprintf(stderr, "expected scope \"%s\", got NULL\n", expected_scope); - return -1; - } - - if (strcmp(expected_scope, req->scope) != 0) - { - fprintf(stderr, "expected scope \"%s\", got \"%s\"\n", expected_scope, req->scope); - return -1; - } - } - - if (expected_issuer) - { - if (!req2) - { - fprintf(stderr, "--expected-issuer cannot be combined with -v1\n"); - return -1; - } - - if (!req2->issuer) - { - fprintf(stderr, "expected issuer \"%s\", got NULL\n", expected_issuer); - return -1; - } - - if (strcmp(expected_issuer, req2->issuer) != 0) - { - fprintf(stderr, "expected issuer \"%s\", got \"%s\"\n", expected_issuer, req2->issuer); - return -1; - } - } - - if (errmsg) - { - if (token) - { - fprintf(stderr, "--error cannot be combined with --token\n"); - return -1; - } - else if (!req2) - { - fprintf(stderr, "--error cannot be combined with -v1\n"); - return -1; - } - - req2->error = errmsg; - return -1; - } - - req->token = token; - return 1; -} - -static PostgresPollingStatusType -async_cb(PGconn *conn, PGoauthBearerRequest *req, pgsocket *altsock) -{ - if (hang_forever) - { - /* - * This code tests that nothing is interfering with libpq's handling - * of connect_timeout. - */ - static pgsocket sock = PGINVALID_SOCKET; - - if (sock == PGINVALID_SOCKET) - { - /* First call. Create an unbound socket to wait on. */ -#ifdef WIN32 - WSADATA wsaData; - int err; - - err = WSAStartup(MAKEWORD(2, 2), &wsaData); - if (err) - { - perror("WSAStartup failed"); - return PGRES_POLLING_FAILED; - } -#endif - sock = socket(AF_INET, SOCK_DGRAM, 0); - if (sock == PGINVALID_SOCKET) - { - perror("failed to create datagram socket"); - return PGRES_POLLING_FAILED; - } - } - - /* Make libpq wait on the (unreadable) socket. */ - *altsock = sock; - return PGRES_POLLING_READING; - } - - req->token = token; - return PGRES_POLLING_OK; -} - -static PostgresPollingStatusType -misbehave_cb(PGconn *conn, PGoauthBearerRequest *req, pgsocket *altsock) -{ - if (strcmp(misbehave_mode, "fail-async") == 0) - { - /* Just fail "normally". */ - if (errmsg) - { - PGoauthBearerRequestV2 *req2; - - if (hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN) - { - fprintf(stderr, "--error cannot be combined with -v1\n"); - exit(1); - } - - req2 = (PGoauthBearerRequestV2 *) req; - req2->error = errmsg; - } - - return PGRES_POLLING_FAILED; - } - else if (strcmp(misbehave_mode, "no-token") == 0) - { - /* Callbacks must assign req->token before returning OK. */ - return PGRES_POLLING_OK; - } - else if (strcmp(misbehave_mode, "no-socket") == 0) - { - /* Callbacks must assign *altsock before asking for polling. */ - return PGRES_POLLING_READING; - } - else - { - fprintf(stderr, "unrecognized --misbehave mode: %s\n", misbehave_mode); - exit(1); - } -} diff --git a/src/test/modules/oauth_validator/oauth_test_common.c b/src/test/modules/oauth_validator/oauth_test_common.c new file mode 100644 index 00000000000..eba8c32eace --- /dev/null +++ b/src/test/modules/oauth_validator/oauth_test_common.c @@ -0,0 +1,374 @@ +/*------------------------------------------------------------------------- + * + * oauth_test_common.c + * Shared functionality for oauth_hook_client and oauth_flow + * + * Copyright (c) 2025, PostgreSQL Global Development Group + * + *------------------------------------------------------------------------- + */ + +#include "postgres_fe.h" + +#include + +#include "getopt_long.h" +#include "libpq-fe.h" +#include "pqexpbuffer.h" + +#include "oauth_test_common.h" + +static PostgresPollingStatusType async_cb(PGconn *conn, + PGoauthBearerRequest *req, + pgsocket *altsock); +static PostgresPollingStatusType misbehave_cb(PGconn *conn, + PGoauthBearerRequest *req, + pgsocket *altsock); + +/* --options */ +static bool no_hook = false; +static bool hang_forever = false; +static const char *expected_uri = NULL; +static const char *expected_issuer = NULL; +static const char *expected_scope = NULL; +static const char *misbehave_mode = NULL; +static char *token = NULL; +static char *errmsg = NULL; +static int hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN_V2; + +/* + * XXX: stress_async is exported for the benefit of oauth_hook_client. Since + * we only use public headers (libpq-fe.h) for oauth_flow, it needs to be an int + * rather than a bool. + */ +int stress_async = false; + +static void +usage(char *argv[]) +{ + printf("usage: %s [flags] CONNINFO\n\n", argv[0]); + + printf("recognized flags:\n"); + printf(" -h, --help show this message\n"); + printf(" -v VERSION select the hook API version (default 2)\n"); + printf(" --expected-scope SCOPE fail if received scopes do not match SCOPE\n"); + printf(" --expected-uri URI fail if received configuration link does not match URI\n"); + printf(" --expected-issuer ISS fail if received issuer does not match ISS (v2 only)\n"); + printf(" --misbehave=MODE have the hook fail required postconditions\n" + " (MODEs: no-hook, fail-async, no-token, no-socket)\n"); + printf(" --no-hook don't install OAuth hooks\n"); + printf(" --hang-forever don't ever return a token (combine with connect_timeout)\n"); + printf(" --token TOKEN use the provided TOKEN value\n"); + printf(" --error ERRMSG fail instead, with the given ERRMSG (v2 only)\n"); + printf(" --stress-async busy-loop on PQconnectPoll rather than polling\n"); +} + +char * +oauth_test_parse_argv(int argc, char *argv[], int for_plugin) +{ + static const struct option long_options[] = { + {"help", no_argument, NULL, 'h'}, + + {"expected-scope", required_argument, NULL, 1000}, + {"expected-uri", required_argument, NULL, 1001}, + {"no-hook", no_argument, NULL, 1002}, + {"token", required_argument, NULL, 1003}, + {"hang-forever", no_argument, NULL, 1004}, + {"misbehave", required_argument, NULL, 1005}, + {"stress-async", no_argument, NULL, 1006}, + {"expected-issuer", required_argument, NULL, 1007}, + {"error", required_argument, NULL, 1008}, + {0} + }; + + int c; + + if (for_plugin) + { + /* The "real" argv has already been parsed. Reset optind. */ + optind = 1; + } + + while ((c = getopt_long(argc, argv, "hv:", long_options, NULL)) != -1) + { + switch (c) + { + case 'h': + usage(argv); + exit(0); + + case 'v': + if (strcmp(optarg, "1") == 0) + hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN; + else if (strcmp(optarg, "2") == 0) + hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN_V2; + else + { + usage(argv); + exit(1); + } + break; + + case 1000: /* --expected-scope */ + expected_scope = optarg; + break; + + case 1001: /* --expected-uri */ + expected_uri = optarg; + break; + + case 1002: /* --no-hook */ + no_hook = true; + break; + + case 1003: /* --token */ + token = optarg; + break; + + case 1004: /* --hang-forever */ + hang_forever = true; + break; + + case 1005: /* --misbehave */ + misbehave_mode = optarg; + break; + + case 1006: /* --stress-async */ + stress_async = true; + break; + + case 1007: /* --expected-issuer */ + expected_issuer = optarg; + break; + + case 1008: /* --error */ + errmsg = optarg; + break; + + default: + usage(argv); + exit(1); + } + } + + if (argc != (for_plugin ? optind : optind + 1)) + { + usage(argv); + exit(1); + } + + return argv[optind]; +} + +/* + * PQauthDataHook implementation. Replaces the default client flow by handling + * PQAUTHDATA_OAUTH_BEARER_TOKEN[_V2]. + */ +int +oauth_test_authdata_hook(PGauthData type, PGconn *conn, void *data) +{ + PGoauthBearerRequest *req; + PGoauthBearerRequestV2 *req2 = NULL; + + Assert(hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN || + hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN_V2); + + if (no_hook || type != hook_version) + return 0; + + req = data; + if (type == PQAUTHDATA_OAUTH_BEARER_TOKEN_V2) + req2 = data; + + if (hang_forever) + { + /* Start asynchronous processing. */ + req->async = async_cb; + return 1; + } + + if (misbehave_mode) + { + if (strcmp(misbehave_mode, "no-hook") != 0) + req->async = misbehave_cb; + return 1; + } + + if (expected_uri) + { + if (!req->openid_configuration) + { + fprintf(stderr, "expected URI \"%s\", got NULL\n", expected_uri); + return -1; + } + + if (strcmp(expected_uri, req->openid_configuration) != 0) + { + fprintf(stderr, "expected URI \"%s\", got \"%s\"\n", expected_uri, req->openid_configuration); + return -1; + } + } + + if (expected_scope) + { + if (!req->scope) + { + fprintf(stderr, "expected scope \"%s\", got NULL\n", expected_scope); + return -1; + } + + if (strcmp(expected_scope, req->scope) != 0) + { + fprintf(stderr, "expected scope \"%s\", got \"%s\"\n", expected_scope, req->scope); + return -1; + } + } + + if (expected_issuer) + { + if (!req2) + { + fprintf(stderr, "--expected-issuer cannot be combined with -v1\n"); + return -1; + } + + if (!req2->issuer) + { + fprintf(stderr, "expected issuer \"%s\", got NULL\n", expected_issuer); + return -1; + } + + if (strcmp(expected_issuer, req2->issuer) != 0) + { + fprintf(stderr, "expected issuer \"%s\", got \"%s\"\n", expected_issuer, req2->issuer); + return -1; + } + } + + if (errmsg) + { + if (token) + { + fprintf(stderr, "--error cannot be combined with --token\n"); + return -1; + } + else if (!req2) + { + fprintf(stderr, "--error cannot be combined with -v1\n"); + return -1; + } + + req2->error = errmsg; + return -1; + } + + req->token = token; + return 1; +} + +/* + * Sets up a request for a plugin module (pg_start_oauthbearer()) rather than + * using the hook. + */ +int +oauth_test_start_flow(PGconn *conn, PGoauthBearerRequestV2 *request) +{ + int ret; + + /* + * We can still defer to the hook above to avoid copying code; we just + * have to translate the return value. + */ + ret = oauth_test_authdata_hook(PQAUTHDATA_OAUTH_BEARER_TOKEN_V2, conn, + request); + + if (ret == 0) + { + /* This is a bug in the test. */ + fprintf(stderr, "plugin tests cannot make use of -v1 or --no-hook\n"); + exit(1); + } + + return (ret == 1) ? 0 : -1; +} + +static PostgresPollingStatusType +async_cb(PGconn *conn, PGoauthBearerRequest *req, pgsocket *altsock) +{ + if (hang_forever) + { + /* + * This code tests that nothing is interfering with libpq's handling + * of connect_timeout. + */ + static pgsocket sock = PGINVALID_SOCKET; + + if (sock == PGINVALID_SOCKET) + { + /* First call. Create an unbound socket to wait on. */ +#ifdef WIN32 + WSADATA wsaData; + int err; + + err = WSAStartup(MAKEWORD(2, 2), &wsaData); + if (err) + { + perror("WSAStartup failed"); + return PGRES_POLLING_FAILED; + } +#endif + sock = socket(AF_INET, SOCK_DGRAM, 0); + if (sock == PGINVALID_SOCKET) + { + perror("failed to create datagram socket"); + return PGRES_POLLING_FAILED; + } + } + + /* Make libpq wait on the (unreadable) socket. */ + *altsock = sock; + return PGRES_POLLING_READING; + } + + req->token = token; + return PGRES_POLLING_OK; +} + +static PostgresPollingStatusType +misbehave_cb(PGconn *conn, PGoauthBearerRequest *req, pgsocket *altsock) +{ + if (strcmp(misbehave_mode, "fail-async") == 0) + { + /* Just fail "normally". */ + if (errmsg) + { + PGoauthBearerRequestV2 *req2; + + if (hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN) + { + fprintf(stderr, "--error cannot be combined with -v1\n"); + exit(1); + } + + req2 = (PGoauthBearerRequestV2 *) req; + req2->error = errmsg; + } + + return PGRES_POLLING_FAILED; + } + else if (strcmp(misbehave_mode, "no-token") == 0) + { + /* Callbacks must assign req->token before returning OK. */ + return PGRES_POLLING_OK; + } + else if (strcmp(misbehave_mode, "no-socket") == 0) + { + /* Callbacks must assign *altsock before asking for polling. */ + return PGRES_POLLING_READING; + } + else + { + fprintf(stderr, "unrecognized --misbehave mode: %s\n", misbehave_mode); + exit(1); + } +} diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl index 12af35b7d96..b50451efb74 100644 --- a/src/test/modules/oauth_validator/t/002_client.pl +++ b/src/test/modules/oauth_validator/t/002_client.pl @@ -1,6 +1,6 @@ # # Exercises the API for custom OAuth client flows, using the oauth_hook_client -# test driver. +# test driver and the oauth_flow custom plugin. # # Copyright (c) 2021-2026, PostgreSQL Global Development Group # @@ -20,6 +20,10 @@ if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/) 'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA'; } +my $plugin_supported = ( + check_pg_config("#define HAVE_SYS_EVENT_H 1") + or check_pg_config("#define HAVE_SYS_EPOLL_H 1")); + # # Cluster Setup # @@ -72,6 +76,8 @@ sub test $flags = $params{flags}; } + # First run the oauth_hook_client, which uses PQauthDataHook to insert a new + # OAuth flow. my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr); note "running '" . join("' '", @cmd) . "'"; @@ -103,6 +109,37 @@ sub test $node->log_check("$test_name: log matches", $log_start, log_like => $params{log_like}); } + + SKIP: + { + last SKIP if $params{hook_only}; + skip "OAuth modules are not supported on this platform" + unless $plugin_supported; + + # Run the same test with psql itself, loading the oauth_flow.so module. + local $ENV{PGOAUTHMODULE} = $ENV{flow_module_path}; + + # Flags are passed to the module via OAUTH_TEST_FLAGS, with 0x01 as a + # separator. + local $ENV{OAUTH_TEST_FLAGS} = join("\x01", @{$flags}); + + if ($params{expect_success}) + { + $node->connect_ok( + $common_connstr, + "[plugin flow] $test_name", + expected_stderr => $params{expected_stderr}, + log_like => $params{log_like}); + } + else + { + $node->connect_fails( + $common_connstr, + "[plugin flow] $test_name", + expected_stderr => $params{expected_stderr}, + log_like => $params{log_like}); + } + } } test( @@ -119,6 +156,7 @@ test( # Make sure the v1 hook continues to work. test( "v1 synchronous hook can provide a token", + hook_only => 1, # plugins don't support API v1 flags => [ "-v1", "--token" => "my-token-v1", @@ -133,6 +171,7 @@ if ($ENV{with_libcurl} ne 'yes') # libpq should help users out if no OAuth support is built in. test( "fails without custom hook installed", + hook_only => 1, # plugins can't use --no-hook flags => ["--no-hook"], expected_stderr => qr/no OAuth flows are available \(try installing the libpq-oauth package\)/ @@ -197,4 +236,19 @@ test( expected_stderr => qr/user-defined OAuth flow failed: async error message/); +SKIP: +{ + skip "OAuth modules are not supported on this platform" + unless $plugin_supported; + + # Make sure a misaimed PGOAUTHMODULE gives the correct error message. + local $ENV{PGOAUTHMODULE} = $node->basedir . '/nonexistent.so'; + + $node->connect_fails( + $common_connstr, + "PGOAUTHMODULE error messages", + expected_stderr => + qr/user-defined OAuth flow failed: plugin could not be loaded/); +} + done_testing(); -- 2.34.1