v20-0006-Introduce-OAuth-validator-libraries.patch
application/octet-stream
Filename: v20-0006-Introduce-OAuth-validator-libraries.patch
Type: application/octet-stream
Part: 6
Patch
Same data as JSON:
GET /api/v1/attachments/:id/patch
the parsed metadata as JSON — format, series position, per-file stats; never the diff bytes.
API reference →
Format: format-patch
Series: patch v20-0006
Subject: Introduce OAuth validator libraries
| File | + | − |
|---|---|---|
| src/backend/libpq/auth-oauth.c | 107 | 324 |
| src/backend/utils/misc/guc_tables.c | 3 | 3 |
| src/bin/pg_combinebackup/Makefile | 1 | 1 |
| src/common/Makefile | 1 | 1 |
| src/include/libpq/oauth.h | 27 | 2 |
| src/test/modules/meson.build | 1 | 0 |
| src/test/modules/oauth_validator/expected/validator.out | 6 | 0 |
| src/test/modules/oauth_validator/.gitignore | 4 | 0 |
| src/test/modules/oauth_validator/Makefile | 19 | 0 |
| src/test/modules/oauth_validator/meson.build | 33 | 0 |
| src/test/modules/oauth_validator/sql/validator.sql | 1 | 0 |
| src/test/modules/oauth_validator/t/001_server.pl | 78 | 0 |
| src/test/modules/oauth_validator/validator.c | 82 | 0 |
| src/test/perl/PostgreSQL/Test/Cluster.pm | 13 | 1 |
| src/test/perl/PostgreSQL/Test/OAuthServer.pm | 183 | 0 |
| src/tools/pgindent/typedefs.list | 2 | 0 |
From fdbad1976a78d179b104138c31ac106e20338b0f Mon Sep 17 00:00:00 2001
From: Daniel Gustafsson <daniel@yesql.se>
Date: Wed, 21 Feb 2024 17:04:26 +0100
Subject: [PATCH v20 6/9] Introduce OAuth validator libraries
This replaces the serverside validation code with an module API
for loading in extensions for validating bearer tokens. A lot of
code is left to be written.
Co-authored-by: Jacob Champion <jacob.champion@enterprisedb.com>
---
src/backend/libpq/auth-oauth.c | 431 +++++-------------
src/backend/utils/misc/guc_tables.c | 6 +-
src/bin/pg_combinebackup/Makefile | 2 +-
src/common/Makefile | 2 +-
src/include/libpq/oauth.h | 29 +-
src/test/modules/meson.build | 1 +
src/test/modules/oauth_validator/.gitignore | 4 +
src/test/modules/oauth_validator/Makefile | 19 +
.../oauth_validator/expected/validator.out | 6 +
src/test/modules/oauth_validator/meson.build | 33 ++
.../modules/oauth_validator/sql/validator.sql | 1 +
.../modules/oauth_validator/t/001_server.pl | 78 ++++
src/test/modules/oauth_validator/validator.c | 82 ++++
src/test/perl/PostgreSQL/Test/Cluster.pm | 14 +-
src/test/perl/PostgreSQL/Test/OAuthServer.pm | 183 ++++++++
src/tools/pgindent/typedefs.list | 2 +
16 files changed, 561 insertions(+), 332 deletions(-)
create mode 100644 src/test/modules/oauth_validator/.gitignore
create mode 100644 src/test/modules/oauth_validator/Makefile
create mode 100644 src/test/modules/oauth_validator/expected/validator.out
create mode 100644 src/test/modules/oauth_validator/meson.build
create mode 100644 src/test/modules/oauth_validator/sql/validator.sql
create mode 100644 src/test/modules/oauth_validator/t/001_server.pl
create mode 100644 src/test/modules/oauth_validator/validator.c
create mode 100644 src/test/perl/PostgreSQL/Test/OAuthServer.pm
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
index 16596c089a..024f304e4d 100644
--- a/src/backend/libpq/auth-oauth.c
+++ b/src/backend/libpq/auth-oauth.c
@@ -6,7 +6,7 @@
* See the following RFC for more details:
* - RFC 7628: https://tools.ietf.org/html/rfc7628
*
- * Portions Copyright (c) 1996-2021, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
* Portions Copyright (c) 1994, Regents of the University of California
*
* src/backend/libpq/auth-oauth.c
@@ -19,22 +19,30 @@
#include <fcntl.h>
#include "common/oauth-common.h"
+#include "fmgr.h"
#include "lib/stringinfo.h"
#include "libpq/auth.h"
#include "libpq/hba.h"
#include "libpq/oauth.h"
#include "libpq/sasl.h"
#include "storage/fd.h"
+#include "storage/ipc.h"
#include "utils/json.h"
/* GUC */
-char *oauth_validator_command;
+char *OAuthValidatorLibrary = "";
static void oauth_get_mechanisms(Port *port, StringInfo buf);
static void *oauth_init(Port *port, const char *selected_mech, const char *shadow_pass);
static int oauth_exchange(void *opaq, const char *input, int inputlen,
char **output, int *outputlen, const char **logdetail);
+static void load_validator_library(void);
+static void shutdown_validator_library(int code, Datum arg);
+
+static ValidatorModuleState *validator_module_state;
+static const OAuthValidatorCallbacks *ValidatorCallbacks;
+
/* Mechanism declaration */
const pg_be_sasl_mech pg_be_oauth_mech = {
oauth_get_mechanisms,
@@ -63,11 +71,7 @@ struct oauth_ctx
static char *sanitize_char(char c);
static char *parse_kvpairs_for_auth(char **input);
static void generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen);
-static bool validate(Port *port, const char *auth, const char **logdetail);
-static bool run_validator_command(Port *port, const char *token);
-static bool check_exit(FILE **fh, const char *command);
-static bool set_cloexec(int fd);
-static bool username_ok_for_shell(const char *username);
+static bool validate(Port *port, const char *auth);
#define KVSEP 0x01
#define AUTH_KEY "auth"
@@ -100,6 +104,8 @@ oauth_init(Port *port, const char *selected_mech, const char *shadow_pass)
ctx->issuer = port->hba->oauth_issuer;
ctx->scope = port->hba->oauth_scope;
+ load_validator_library();
+
return ctx;
}
@@ -250,7 +256,7 @@ oauth_exchange(void *opaq, const char *input, int inputlen,
errmsg("malformed OAUTHBEARER message"),
errdetail("Message contains additional data after the final terminator.")));
- if (!validate(ctx->port, auth, logdetail))
+ if (!validate(ctx->port, auth))
{
generate_error_response(ctx, output, outputlen);
@@ -489,70 +495,73 @@ generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen)
*outputlen = buf.len;
}
-static bool
-validate(Port *port, const char *auth, const char **logdetail)
+/*-----
+ * Validates the provided Authorization header and returns the token from
+ * within it. NULL is returned on validation failure.
+ *
+ * Only Bearer tokens are accepted. The ABNF is defined in RFC 6750, Sec.
+ * 2.1:
+ *
+ * b64token = 1*( ALPHA / DIGIT /
+ * "-" / "." / "_" / "~" / "+" / "/" ) *"="
+ * credentials = "Bearer" 1*SP b64token
+ *
+ * The "credentials" construction is what we receive in our auth value.
+ *
+ * Since that spec is subordinate to HTTP (i.e. the HTTP Authorization
+ * header format; RFC 7235 Sec. 2), the "Bearer" scheme string must be
+ * compared case-insensitively. (This is not mentioned in RFC 6750, but
+ * it's pointed out in RFC 7628 Sec. 4.)
+ *
+ * Invalid formats are technically a protocol violation, but we shouldn't
+ * reflect any information about the sensitive Bearer token back to the
+ * client; log at COMMERROR instead.
+ *
+ * TODO: handle the Authorization spec, RFC 7235 Sec. 2.1.
+ */
+static const char *
+validate_token_format(const char *header)
{
- static const char *const b64_set =
+ size_t span;
+ const char *token;
+ static const char *const b64token_allowed_set =
"abcdefghijklmnopqrstuvwxyz"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"0123456789-._~+/";
- const char *token;
- size_t span;
- int ret;
+ /* If the token is empty or simply too short to be correct */
+ if (!header || strlen(header) <= 7)
+ {
+ ereport(COMMERROR,
+ (errmsg("malformed OAuth bearer token 1")));
+ return NULL;
+ }
- /* TODO: handle logdetail when the test framework can check it */
-
- /*-----
- * Only Bearer tokens are accepted. The ABNF is defined in RFC 6750, Sec.
- * 2.1:
- *
- * b64token = 1*( ALPHA / DIGIT /
- * "-" / "." / "_" / "~" / "+" / "/" ) *"="
- * credentials = "Bearer" 1*SP b64token
- *
- * The "credentials" construction is what we receive in our auth value.
- *
- * Since that spec is subordinate to HTTP (i.e. the HTTP Authorization
- * header format; RFC 7235 Sec. 2), the "Bearer" scheme string must be
- * compared case-insensitively. (This is not mentioned in RFC 6750, but
- * it's pointed out in RFC 7628 Sec. 4.)
- *
- * TODO: handle the Authorization spec, RFC 7235 Sec. 2.1.
- */
- if (pg_strncasecmp(auth, BEARER_SCHEME, strlen(BEARER_SCHEME)))
- return false;
+ if (pg_strncasecmp(header, BEARER_SCHEME, strlen(BEARER_SCHEME)))
+ return NULL;
/* Pull the bearer token out of the auth value. */
- token = auth + strlen(BEARER_SCHEME);
+ token = header + strlen(BEARER_SCHEME);
/* Swallow any additional spaces. */
while (*token == ' ')
token++;
- /*
- * Before invoking the validator command, sanity-check the token format to
- * avoid any injection attacks later in the chain. Invalid formats are
- * technically a protocol violation, but don't reflect any information
- * about the sensitive Bearer token back to the client; log at COMMERROR
- * instead.
- */
-
/* Tokens must not be empty. */
if (!*token)
{
ereport(COMMERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
- errmsg("malformed OAUTHBEARER message"),
+ errmsg("malformed OAuth bearer token 2"),
errdetail("Bearer token is empty.")));
- return false;
+ return NULL;
}
/*
* Make sure the token contains only allowed characters. Tokens may end
* with any number of '=' characters.
*/
- span = strspn(token, b64_set);
+ span = strspn(token, b64token_allowed_set);
while (token[span] == '=')
span++;
@@ -565,15 +574,35 @@ validate(Port *port, const char *auth, const char **logdetail)
*/
ereport(COMMERROR,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
- errmsg("malformed OAUTHBEARER message"),
+ errmsg("malformed OAuth bearer token 3"),
errdetail("Bearer token is not in the correct format.")));
- return false;
+ return NULL;
}
- /* Have the validator check the token. */
- if (!run_validator_command(port, token))
+ return token;
+}
+
+static bool
+validate(Port *port, const char *auth)
+{
+ int map_status;
+ ValidatorModuleResult *ret;
+ const char *token;
+
+ /* Ensure that we have a correct token to validate */
+ if (!(token = validate_token_format(auth)))
+ return false;
+
+ /* Call the validation function from the validator module */
+ ret = ValidatorCallbacks->validate_cb(validator_module_state,
+ token, port->user_name);
+
+ if (!ret->authorized)
return false;
+ if (ret->authn_id)
+ set_authn_id(port, ret->authn_id);
+
if (port->hba->oauth_skip_usermap)
{
/*
@@ -586,7 +615,7 @@ validate(Port *port, const char *auth, const char **logdetail)
}
/* Make sure the validator authenticated the user. */
- if (!MyClientConnectionInfo.authn_id)
+ if (ret->authn_id == NULL || ret->authn_id[0] == '\0')
{
/* TODO: use logdetail; reduce message duplication */
ereport(LOG,
@@ -596,288 +625,42 @@ validate(Port *port, const char *auth, const char **logdetail)
}
/* Finally, check the user map. */
- ret = check_usermap(port->hba->usermap, port->user_name,
- MyClientConnectionInfo.authn_id, false);
- return (ret == STATUS_OK);
-}
-
-static bool
-run_validator_command(Port *port, const char *token)
-{
- bool success = false;
- int rc;
- int pipefd[2];
- int rfd = -1;
- int wfd = -1;
-
- StringInfoData command = {0};
- char *p;
- FILE *fh = NULL;
-
- ssize_t written;
- char *line = NULL;
- size_t size = 0;
- ssize_t len;
-
- Assert(oauth_validator_command);
-
- if (!oauth_validator_command[0])
- {
- ereport(COMMERROR,
- (errmsg("oauth_validator_command is not set"),
- errhint("To allow OAuth authenticated connections, set "
- "oauth_validator_command in postgresql.conf.")));
- return false;
- }
-
- /*------
- * Since popen() is unidirectional, open up a pipe for the other
- * direction. Use CLOEXEC to ensure that our write end doesn't
- * accidentally get copied into child processes, which would prevent us
- * from closing it cleanly.
- *
- * XXX this is ugly. We should just read from the child process's stdout,
- * but that's a lot more code.
- * XXX by bypassing the popen API, we open the potential of process
- * deadlock. Clearly document child process requirements (i.e. the child
- * MUST read all data off of the pipe before writing anything).
- * TODO: port to Windows using _pipe().
- */
- rc = pipe(pipefd);
- if (rc < 0)
- {
- ereport(COMMERROR,
- (errcode_for_file_access(),
- errmsg("could not create child pipe: %m")));
- return false;
- }
-
- rfd = pipefd[0];
- wfd = pipefd[1];
-
- if (!set_cloexec(wfd))
- {
- /* error message was already logged */
- goto cleanup;
- }
-
- /*----------
- * Construct the command, substituting any recognized %-specifiers:
- *
- * %f: the file descriptor of the input pipe
- * %r: the role that the client wants to assume (port->user_name)
- * %%: a literal '%'
- */
- initStringInfo(&command);
-
- for (p = oauth_validator_command; *p; p++)
- {
- if (p[0] == '%')
- {
- switch (p[1])
- {
- case 'f':
- appendStringInfo(&command, "%d", rfd);
- p++;
- break;
- case 'r':
-
- /*
- * TODO: decide how this string should be escaped. The
- * role is controlled by the client, so if we don't escape
- * it, command injections are inevitable.
- *
- * This is probably an indication that the role name needs
- * to be communicated to the validator process in some
- * other way. For this proof of concept, just be
- * incredibly strict about the characters that are allowed
- * in user names.
- */
- if (!username_ok_for_shell(port->user_name))
- goto cleanup;
-
- appendStringInfoString(&command, port->user_name);
- p++;
- break;
- case '%':
- appendStringInfoChar(&command, '%');
- p++;
- break;
- default:
- appendStringInfoChar(&command, p[0]);
- }
- }
- else
- appendStringInfoChar(&command, p[0]);
- }
-
- /* Execute the command. */
- fh = OpenPipeStream(command.data, "r");
- if (!fh)
- {
- ereport(COMMERROR,
- (errcode_for_file_access(),
- errmsg("opening pipe to OAuth validator: %m")));
- goto cleanup;
- }
-
- /* We don't need the read end of the pipe anymore. */
- close(rfd);
- rfd = -1;
-
- /* Give the command the token to validate. */
- written = write(wfd, token, strlen(token));
- if (written != strlen(token))
- {
- /* TODO must loop for short writes, EINTR et al */
- ereport(COMMERROR,
- (errcode_for_file_access(),
- errmsg("could not write token to child pipe: %m")));
- goto cleanup;
- }
-
- close(wfd);
- wfd = -1;
-
- /*-----
- * Read the command's response.
- *
- * TODO: getline() is probably too new to use, unfortunately.
- * TODO: loop over all lines
- */
- if ((len = getline(&line, &size, fh)) >= 0)
- {
- /* TODO: fail if the authn_id doesn't end with a newline */
- if (len > 0)
- line[len - 1] = '\0';
-
- set_authn_id(port, line);
- }
- else if (ferror(fh))
- {
- ereport(COMMERROR,
- (errcode_for_file_access(),
- errmsg("could not read from command \"%s\": %m",
- command.data)));
- goto cleanup;
- }
-
- /* Make sure the command exits cleanly. */
- if (!check_exit(&fh, command.data))
- {
- /* error message already logged */
- goto cleanup;
- }
-
- /* Done. */
- success = true;
-
-cleanup:
- if (line)
- free(line);
-
- /*
- * In the successful case, the pipe fds are already closed. For the error
- * case, always close out the pipe before waiting for the command, to
- * prevent deadlock.
- */
- if (rfd >= 0)
- close(rfd);
- if (wfd >= 0)
- close(wfd);
-
- if (fh)
- {
- Assert(!success);
- check_exit(&fh, command.data);
- }
-
- if (command.data)
- pfree(command.data);
-
- return success;
+ map_status = check_usermap(port->hba->usermap, port->user_name,
+ MyClientConnectionInfo.authn_id, false);
+ return (map_status == STATUS_OK);
}
-static bool
-check_exit(FILE **fh, const char *command)
+static void
+load_validator_library(void)
{
- int rc;
-
- rc = ClosePipeStream(*fh);
- *fh = NULL;
-
- if (rc == -1)
- {
- /* pclose() itself failed. */
- ereport(COMMERROR,
- (errcode_for_file_access(),
- errmsg("could not close pipe to command \"%s\": %m",
- command)));
- }
- else if (rc != 0)
- {
- char *reason = wait_result_to_str(rc);
+ OAuthValidatorModuleInit validator_init;
- ereport(COMMERROR,
- (errmsg("failed to execute command \"%s\": %s",
- command, reason)));
+ if (OAuthValidatorLibrary[0] == '\0')
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("oauth_validator_library is not set")));
- pfree(reason);
- }
+ validator_init = (OAuthValidatorModuleInit)
+ load_external_function(OAuthValidatorLibrary,
+ "_PG_oauth_validator_module_init", false, NULL);
- return (rc == 0);
-}
+ if (validator_init == NULL)
+ ereport(ERROR,
+ (errmsg("%s module \"%s\" have to define the symbol %s",
+ "OAuth validator", OAuthValidatorLibrary, "_PG_oauth_validator_module_init")));
-static bool
-set_cloexec(int fd)
-{
- int flags;
- int rc;
+ ValidatorCallbacks = (*validator_init) ();
- flags = fcntl(fd, F_GETFD);
- if (flags == -1)
- {
- ereport(COMMERROR,
- (errcode_for_file_access(),
- errmsg("could not get fd flags for child pipe: %m")));
- return false;
- }
+ validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState));
+ if (ValidatorCallbacks->startup_cb != NULL)
+ ValidatorCallbacks->startup_cb(validator_module_state);
- rc = fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
- if (rc < 0)
- {
- ereport(COMMERROR,
- (errcode_for_file_access(),
- errmsg("could not set FD_CLOEXEC for child pipe: %m")));
- return false;
- }
-
- return true;
+ before_shmem_exit(shutdown_validator_library, 0);
}
-/*
- * XXX This should go away eventually and be replaced with either a proper
- * escape or a different strategy for communication with the validator command.
- */
-static bool
-username_ok_for_shell(const char *username)
+static void
+shutdown_validator_library(int code, Datum arg)
{
- /* This set is borrowed from fe_utils' appendShellStringNoError(). */
- static const char *const allowed =
- "abcdefghijklmnopqrstuvwxyz"
- "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
- "0123456789-_./:";
- size_t span;
-
- Assert(username && username[0]); /* should have already been checked */
-
- span = strspn(username, allowed);
- if (username[span] != '\0')
- {
- ereport(COMMERROR,
- (errmsg("PostgreSQL user name contains unsafe characters and cannot be passed to the OAuth validator")));
- return false;
- }
-
- return true;
+ if (ValidatorCallbacks->shutdown_cb != NULL)
+ ValidatorCallbacks->shutdown_cb(validator_module_state);
}
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index d28209901f..99bb89d54e 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -4672,12 +4672,12 @@ struct config_string ConfigureNamesString[] =
},
{
- {"oauth_validator_command", PGC_SIGHUP, CONN_AUTH_AUTH,
- gettext_noop("Command to validate OAuth v2 bearer tokens."),
+ {"oauth_validator_library", PGC_SIGHUP, CONN_AUTH_AUTH,
+ gettext_noop("Sets the library that will be called to validate OAuth v2 bearer tokens."),
NULL,
GUC_SUPERUSER_ONLY | GUC_NOT_IN_SAMPLE
},
- &oauth_validator_command,
+ &OAuthValidatorLibrary,
"",
NULL, NULL, NULL
},
diff --git a/src/bin/pg_combinebackup/Makefile b/src/bin/pg_combinebackup/Makefile
index c3729755ba..4f24b1aff6 100644
--- a/src/bin/pg_combinebackup/Makefile
+++ b/src/bin/pg_combinebackup/Makefile
@@ -31,7 +31,7 @@ OBJS = \
all: pg_combinebackup
pg_combinebackup: $(OBJS) | submake-libpgport submake-libpgfeutils
- $(CC) $(CFLAGS) $^ $(LDFLAGS) $(LDFLAGS_EX) $(LIBS) -o $@$(X)
+ $(CC) $(CFLAGS) $^ $(LDFLAGS) $(LDFLAGS_EX) $(libpq_pgport) $(LIBS) -o $@$(X)
install: all installdirs
$(INSTALL_PROGRAM) pg_combinebackup$(X) '$(DESTDIR)$(bindir)/pg_combinebackup$(X)'
diff --git a/src/common/Makefile b/src/common/Makefile
index bbb5c3ab11..00e30e6bfe 100644
--- a/src/common/Makefile
+++ b/src/common/Makefile
@@ -41,7 +41,7 @@ override CPPFLAGS += -DVAL_LDFLAGS_SL="\"$(LDFLAGS_SL)\""
override CPPFLAGS += -DVAL_LIBS="\"$(LIBS)\""
override CPPFLAGS := -DFRONTEND -I. -I$(top_srcdir)/src/common -I$(libpq_srcdir) $(CPPFLAGS)
-LIBS += $(PTHREAD_LIBS)
+LIBS += $(PTHREAD_LIBS) $(libpq_pgport)
OBJS_COMMON = \
archive.o \
diff --git a/src/include/libpq/oauth.h b/src/include/libpq/oauth.h
index 5edab3b25a..6f98e84cc9 100644
--- a/src/include/libpq/oauth.h
+++ b/src/include/libpq/oauth.h
@@ -3,7 +3,7 @@
* oauth.h
* Interface to libpq/auth-oauth.c
*
- * Portions Copyright (c) 1996-2021, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
* Portions Copyright (c) 1994, Regents of the University of California
*
* src/include/libpq/oauth.h
@@ -16,7 +16,32 @@
#include "libpq/libpq-be.h"
#include "libpq/sasl.h"
-extern char *oauth_validator_command;
+extern PGDLLIMPORT char *OAuthValidatorLibrary;
+
+typedef struct ValidatorModuleState
+{
+ void *private_data;
+} ValidatorModuleState;
+
+typedef struct ValidatorModuleResult
+{
+ bool authorized;
+ char *authn_id;
+} ValidatorModuleResult;
+
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+
+typedef struct OAuthValidatorCallbacks
+{
+ ValidatorStartupCB startup_cb;
+ ValidatorShutdownCB shutdown_cb;
+ ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+extern PGDLLEXPORT const OAuthValidatorCallbacks *_PG_oauth_validator_module_init(void);
/* Implementation */
extern const pg_be_sasl_mech pg_be_oauth_mech;
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 8fbe742d38..dc54ce7189 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -9,6 +9,7 @@ subdir('gin')
subdir('injection_points')
subdir('ldap_password_func')
subdir('libpq_pipeline')
+subdir('oauth_validator')
subdir('plsample')
subdir('spgist_name_ops')
subdir('ssl_passphrase_callback')
diff --git a/src/test/modules/oauth_validator/.gitignore b/src/test/modules/oauth_validator/.gitignore
new file mode 100644
index 0000000000..5dcb3ff972
--- /dev/null
+++ b/src/test/modules/oauth_validator/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
new file mode 100644
index 0000000000..1f874cd7f2
--- /dev/null
+++ b/src/test/modules/oauth_validator/Makefile
@@ -0,0 +1,19 @@
+MODULES = validator
+PGFILEDESC = "validator - test OAuth validator module"
+
+NO_INSTALLCHECK = 1
+
+TAP_TESTS = 1
+
+REGRESS = validator
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/oauth_validator
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/oauth_validator/expected/validator.out b/src/test/modules/oauth_validator/expected/validator.out
new file mode 100644
index 0000000000..360caa2cb3
--- /dev/null
+++ b/src/test/modules/oauth_validator/expected/validator.out
@@ -0,0 +1,6 @@
+SELECT 1;
+ ?column?
+----------
+ 1
+(1 row)
+
diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build
new file mode 100644
index 0000000000..d9c1d1d577
--- /dev/null
+++ b/src/test/modules/oauth_validator/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+validator_sources = files(
+ 'validator.c',
+)
+
+if host_system == 'windows'
+ validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+ '--NAME', 'validator',
+ '--FILEDESC', 'validator - test OAuth validator module',])
+endif
+
+validator = shared_module('validator',
+ validator_sources,
+ kwargs: pg_test_mod_args,
+)
+test_install_libs += validator
+
+tests += {
+ 'name': 'oauth_validator',
+ 'sd': meson.current_source_dir(),
+ 'bd': meson.current_build_dir(),
+ 'regress': {
+ 'sql': [
+ 'validator',
+ ],
+ },
+ 'tap': {
+ 'tests': [
+ 't/001_server.pl',
+ ],
+ },
+}
diff --git a/src/test/modules/oauth_validator/sql/validator.sql b/src/test/modules/oauth_validator/sql/validator.sql
new file mode 100644
index 0000000000..e0ac49d1ec
--- /dev/null
+++ b/src/test/modules/oauth_validator/sql/validator.sql
@@ -0,0 +1 @@
+SELECT 1;
diff --git a/src/test/modules/oauth_validator/t/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
new file mode 100644
index 0000000000..14c7778298
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -0,0 +1,78 @@
+
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use PostgreSQL::Test::OAuthServer;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf', "shared_preload_libraries = 'validator'\n");
+$node->append_conf('postgresql.conf', "oauth_validator_library = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+$node->safe_psql('postgres', 'CREATE USER testalt;');
+
+my $issuer = "127.0.0.1:18080";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf('pg_hba.conf', qq{
+local all test oauth issuer="$issuer" scope="openid postgres"
+local all testalt oauth issuer="$issuer/alternate" scope="openid postgres alt"
+});
+$node->reload;
+
+my $webserver = PostgreSQL::Test::OAuthServer->new(18080);
+
+my $port = $webserver->port();
+
+is($port, 18080, "Port is 18080");
+
+$webserver->setup();
+$webserver->run();
+
+my ($log_start, $log_end);
+$log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+my $user = "test";
+$node->connect_ok("user=$user dbname=postgres oauth_client_id=f02c6361-0635", "connect",
+ expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$log_end = $node->wait_for_log(qr/connection authorized/, $log_start);
+$node->log_check("user $user: validator receives correct parameters", $log_start,
+ log_like => [
+ qr/oauth_validator: token="9243959234", role="$user"/,
+ qr/oauth_validator: issuer="\Q$issuer\E", scope="openid postgres"/,
+ ]);
+$node->log_check("user $user: validator sets authenticated identity", $log_start,
+ log_like => [
+ qr/connection authenticated: identity="test" method=oauth/,
+ ]);
+$log_start = $log_end;
+
+# The /alternate issuer uses slightly different parameters.
+$user = "testalt";
+$node->connect_ok("user=$user dbname=postgres oauth_client_id=f02c6361-0636", "connect",
+ expected_stderr => qr@Visit https://example\.org/ and enter the code: postgresuser@);
+
+$log_end = $node->wait_for_log(qr/connection authorized/, $log_start);
+$node->log_check("user $user: validator receives correct parameters", $log_start,
+ log_like => [
+ qr/oauth_validator: token="9243959234-alt", role="$user"/,
+ qr|oauth_validator: issuer="\Q$issuer/alternate\E", scope="openid postgres alt"|,
+ ]);
+$node->log_check("user $user: validator sets authenticated identity", $log_start,
+ log_like => [
+ qr/connection authenticated: identity="testalt" method=oauth/,
+ ]);
+$log_start = $log_end;
+
+$node->stop;
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
new file mode 100644
index 0000000000..09a4bf61d2
--- /dev/null
+++ b/src/test/modules/oauth_validator/validator.c
@@ -0,0 +1,82 @@
+/*-------------------------------------------------------------------------
+ *
+ * validator.c
+ * Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "miscadmin.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void validator_startup(ValidatorModuleState *state);
+static void validator_shutdown(ValidatorModuleState *state);
+static ValidatorModuleResult * validate_token(ValidatorModuleState *state,
+ const char *token,
+ const char *role);
+
+static const OAuthValidatorCallbacks validator_callbacks = {
+ .startup_cb = validator_startup,
+ .shutdown_cb = validator_shutdown,
+ .validate_cb = validate_token
+};
+
+void
+_PG_init(void)
+{
+ /* no-op */
+}
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+ return &validator_callbacks;
+}
+
+#define PRIVATE_COOKIE ((void *) 13579)
+
+static void
+validator_startup(ValidatorModuleState *state)
+{
+ state->private_data = PRIVATE_COOKIE;
+}
+
+static void
+validator_shutdown(ValidatorModuleState *state)
+{
+ /* do nothing */
+}
+
+static ValidatorModuleResult *
+validate_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+ ValidatorModuleResult *res;
+
+ /* Check to make sure our private state still exists. */
+ if (state->private_data != PRIVATE_COOKIE)
+ elog(ERROR, "oauth_validator: private state cookie changed to %p",
+ state->private_data);
+
+ res = palloc(sizeof(ValidatorModuleResult));
+
+ elog(LOG, "oauth_validator: token=\"%s\", role=\"%s\"", token, role);
+ elog(LOG, "oauth_validator: issuer=\"%s\", scope=\"%s\"",
+ MyProcPort->hba->oauth_issuer,
+ MyProcPort->hba->oauth_scope);
+
+ res->authorized = true;
+ res->authn_id = pstrdup(role);
+
+ return res;
+}
diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm
index 4fec417f6f..b291bbf8ee 100644
--- a/src/test/perl/PostgreSQL/Test/Cluster.pm
+++ b/src/test/perl/PostgreSQL/Test/Cluster.pm
@@ -2302,6 +2302,11 @@ instead of the default.
If this regular expression is set, matches it with the output generated.
+=item expected_stderr => B<value>
+
+If this regular expression is set, matches it against the standard error
+stream; otherwise the stderr must be empty.
+
=item log_like => [ qr/required message/ ]
=item log_unlike => [ qr/prohibited message/ ]
@@ -2345,7 +2350,14 @@ sub connect_ok
like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
}
- is($stderr, "", "$test_name: no stderr");
+ if (defined($params{expected_stderr}))
+ {
+ like($stderr, $params{expected_stderr}, "$test_name: stderr matches");
+ }
+ else
+ {
+ is($stderr, "", "$test_name: no stderr");
+ }
$self->log_check($test_name, $log_location, %params);
}
diff --git a/src/test/perl/PostgreSQL/Test/OAuthServer.pm b/src/test/perl/PostgreSQL/Test/OAuthServer.pm
new file mode 100644
index 0000000000..5c195efb79
--- /dev/null
+++ b/src/test/perl/PostgreSQL/Test/OAuthServer.pm
@@ -0,0 +1,183 @@
+#!/usr/bin/perl
+
+package PostgreSQL::Test::OAuthServer;
+
+use warnings;
+use strict;
+use threads;
+use Socket;
+use IO::Select;
+
+local *server_socket;
+
+sub new
+{
+ my $class = shift;
+ my $port = shift;
+
+ my $self = {};
+ bless($self, $class);
+
+ $self->{'port'} = $port;
+
+ return $self;
+}
+
+sub setup
+{
+ my $self = shift;
+ my $tcp = getprotobyname('tcp');
+
+ socket($self->{'socket'}, PF_INET, SOCK_STREAM, $tcp)
+ or die "no socket";
+ setsockopt($self->{'socket'}, SOL_SOCKET, SO_REUSEADDR, pack("l", 1));
+ bind($self->{'socket'}, sockaddr_in($self->{'port'}, INADDR_ANY));
+}
+
+sub port
+{
+ my $self = shift;
+
+ return $self->{'port'};
+}
+
+sub run
+{
+ my $self = shift;
+
+ my $server_thread = threads->create(\&_listen, $self);
+ $server_thread->detach();
+}
+
+sub _listen
+{
+ my $self = shift;
+
+ listen($self->{'socket'}, SOMAXCONN) or die "fail to listen: $!";
+
+ while (1)
+ {
+ my $fh;
+ my %request;
+ my $remote = accept($fh, $self->{'socket'});
+ binmode $fh;
+
+ my ($method, $object, $prot) = split(/ /, <$fh>);
+ $request{'method'} = $method;
+ $request{'object'} = $object;
+ chomp($request{'object'});
+
+ local $/ = Socket::CRLF;
+ my $c = 0;
+ while(<$fh>)
+ {
+ chomp;
+ # Headers
+ if (/:/)
+ {
+ my ($field, $value) = split(/:/, $_, 2);
+ $value =~ s/^\s+//;
+ $request{'headers'}{lc $field} = $value;
+ }
+ # POST data
+ elsif (/^$/)
+ {
+ read($fh, $request{'content'}, $request{'headers'}{'content-length'})
+ if defined $request{'headers'}{'content-length'};
+ last;
+ }
+ }
+
+ # Debug printing
+ # print ": read ".$request{'method'} . ";" . $request{'object'}.";\n";
+ # foreach my $h (keys(%{$request{'headers'}}))
+ #{
+ # printf ": headers: " . $request{'headers'}{$h} . "\n";
+ #}
+ #printf ": POST: " . $request{'content'} . "\n" if defined $request{'content'};
+
+ my $alternate = 0;
+ if ($request{'object'} =~ qr|^/alternate(/.*)$|)
+ {
+ $alternate = 1;
+ $request{'object'} = $1;
+ }
+
+ if ($request{'object'} eq '/.well-known/openid-configuration')
+ {
+ my $issuer = "http://localhost:$self->{'port'}";
+ if ($alternate)
+ {
+ $issuer .= "/alternate";
+ }
+
+ print $fh "HTTP/1.0 200 OK\r\nServer: Postgres Regress\r\n";
+ print $fh "Content-Type: application/json\r\n";
+ print $fh "\r\n";
+ print $fh <<EOR;
+ {
+ "issuer": "$issuer",
+ "token_endpoint": "$issuer/token",
+ "device_authorization_endpoint": "$issuer/authorize",
+ "response_types_supported": ["token"],
+ "subject_types_supported": ["public"],
+ "id_token_signing_alg_values_supported": ["RS256"],
+ "grant_types_supported": ["urn:ietf:params:oauth:grant-type:device_code"]
+ }
+EOR
+ }
+ elsif ($request{'object'} eq '/authorize')
+ {
+ my $uri = "https://example.com/";
+ if ($alternate)
+ {
+ $uri = "https://example.org/";
+ }
+
+ print ": returning device_code\n";
+ print $fh "HTTP/1.0 200 OK\r\nServer: Postgres Regress\r\n";
+ print $fh "Content-Type: application/json\r\n";
+ print $fh "\r\n";
+ print $fh <<EOR;
+ {
+ "device_code": "postgres",
+ "user_code" : "postgresuser",
+ "interval" : 0,
+ "verification_uri" : "$uri",
+ "expires-in": 5
+ }
+EOR
+ }
+ elsif ($request{'object'} eq '/token')
+ {
+ my $token = "9243959234";
+ if ($alternate)
+ {
+ $token .= "-alt";
+ }
+
+ print ": returning token\n";
+ print $fh "HTTP/1.0 200 OK\r\nServer: Postgres Regress\r\n";
+ print $fh "Content-Type: application/json\r\n";
+ print $fh "\r\n";
+ print $fh <<EOR;
+ {
+ "access_token": "$token",
+ "token_type": "bearer"
+ }
+EOR
+ }
+ else
+ {
+ print ": returning default\n";
+ print $fh "HTTP/1.0 200 OK\r\nServer: Postgres Regress\r\n";
+ print $fh "Content-Type: text/html\r\n";
+ print $fh "\r\n";
+ print $fh "Ok\n";
+ }
+
+ close($fh);
+ }
+}
+
+1;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 8119523cd9..8558f3b3e6 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1657,6 +1657,7 @@ NumericSortSupport
NumericSumAccum
NumericVar
OAuthStep
+OAuthValidatorCallbacks
OM_uint32
OP
OSAPerGroupState
@@ -2980,6 +2981,7 @@ VacuumRelation
VacuumStmt
ValidIOData
ValidateIndexState
+ValidatorModuleState
ValuesScan
ValuesScanState
Var
--
2.34.1