since-v19.diff.txt
text/plain
Filename: since-v19.diff.txt
Type: text/plain
Part: 0
1: ce06c03e2b = 1: 231c6fb165 common/jsonapi: support FRONTEND clients
2: 6989b75153 = 2: f78c79ea68 Refactor SASL exchange to return tri-state status
3: 783bfe0b95 = 3: 10b6d2a6b9 Explicitly require password for SCRAM exchange
4: 77550a47db = 4: 2a55d9c806 libpq: add OAUTHBEARER SASL mechanism
5: 12ae7c4355 ! 5: 5488ac25f5 backend: add OAUTHBEARER SASL mechanism
@@ Commit message
- port to platforms other than "modern Linux/BSD"
- overhaul the communication with oauth_validator_command, which is
currently a bad hack on OpenPipeStream()
- - implement more sanity checks on the OAUTHBEARER message format and
- tokens sent by the client
- implement more helpful handling of HBA misconfigurations
- - properly interpolate JSON when generating error responses
- use logdetail during auth failures
- deal with role names that can't be safely passed to system() without
shell-escaping
@@ src/backend/libpq/auth-oauth.c (new)
+#include "libpq/oauth.h"
+#include "libpq/sasl.h"
+#include "storage/fd.h"
++#include "utils/json.h"
+
+/* GUC */
+char *oauth_validator_command;
@@ src/backend/libpq/auth-oauth.c (new)
+}
+
+/*
++ * Performs syntactic validation of a key and value from the initial client
++ * response. (Semantic validation of interesting values must be performed
++ * later.)
++ */
++static void
++validate_kvpair(const char *key, const char *val)
++{
++ /*-----
++ * From Sec 3.1:
++ * key = 1*(ALPHA)
++ */
++ static const char *key_allowed_set =
++ "abcdefghijklmnopqrstuvwxyz"
++ "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
++
++ size_t span;
++
++ if (!key[0])
++ ereport(ERROR,
++ (errcode(ERRCODE_PROTOCOL_VIOLATION),
++ errmsg("malformed OAUTHBEARER message"),
++ errdetail("Message contains an empty key name.")));
++
++ span = strspn(key, key_allowed_set);
++ if (key[span] != '\0')
++ ereport(ERROR,
++ (errcode(ERRCODE_PROTOCOL_VIOLATION),
++ errmsg("malformed OAUTHBEARER message"),
++ errdetail("Message contains an invalid key name.")));
++
++ /*-----
++ * From Sec 3.1:
++ * value = *(VCHAR / SP / HTAB / CR / LF )
++ *
++ * The VCHAR (visible character) class is large; a loop is more
++ * straightforward than strspn().
++ */
++ for (; *val; ++val)
++ {
++ if (0x21 <= *val && *val <= 0x7E)
++ continue; /* VCHAR */
++
++ switch (*val)
++ {
++ case ' ':
++ case '\t':
++ case '\r':
++ case '\n':
++ continue; /* SP, HTAB, CR, LF */
++
++ default:
++ ereport(ERROR,
++ (errcode(ERRCODE_PROTOCOL_VIOLATION),
++ errmsg("malformed OAUTHBEARER message"),
++ errdetail("Message contains an invalid value.")));
++ }
++ }
++}
++
++/*
+ * Consumes all kvpairs in an OAUTHBEARER exchange message. If the "auth" key is
+ * found, its value is returned.
+ */
@@ src/backend/libpq/auth-oauth.c (new)
+
+ /*
+ * Find the end of the key name.
-+ *
-+ * TODO further validate the key/value grammar? empty keys, bad
-+ * chars...
+ */
+ sep = strchr(pos, '=');
+ if (!sep)
@@ src/backend/libpq/auth-oauth.c (new)
+ /* Both key and value are now safely terminated. */
+ key = pos;
+ value = sep + 1;
++ validate_kvpair(key, value);
+
+ if (!strcmp(key, AUTH_KEY))
+ {
@@ src/backend/libpq/auth-oauth.c (new)
+generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen)
+{
+ StringInfoData buf;
++ StringInfoData issuer;
+
+ /*
+ * The admin needs to set an issuer and scope for OAuth to work. There's
@@ src/backend/libpq/auth-oauth.c (new)
+ errmsg("OAuth is not properly configured for this user"),
+ errdetail_log("The issuer and scope parameters must be set in pg_hba.conf.")));
+
++ /*------
++ * Build the .well-known URI based on our issuer.
++ * TODO: RFC 8414 defines a competing well-known URI, so we'll probably
++ * have to make this configurable too.
++ */
++ initStringInfo(&issuer);
++ appendStringInfoString(&issuer, ctx->issuer);
++ appendStringInfoString(&issuer, "/.well-known/openid-configuration");
+
+ initStringInfo(&buf);
+
+ /*
-+ * TODO: JSON escaping
++ * TODO: note that escaping here should be belt-and-suspenders, since
++ * escapable characters aren't valid in either the issuer URI or the scope
++ * list, but the HBA doesn't enforce that yet.
+ */
-+ appendStringInfo(&buf,
-+ "{ "
-+ "\"status\": \"invalid_token\", "
-+ "\"openid-configuration\": \"%s/.well-known/openid-configuration\", "
-+ "\"scope\": \"%s\" "
-+ "}",
-+ ctx->issuer, ctx->scope);
++ appendStringInfoString(&buf, "{ \"status\": \"invalid_token\", ");
++
++ appendStringInfoString(&buf, "\"openid-configuration\": ");
++ escape_json(&buf, issuer.data);
++ pfree(issuer.data);
++
++ appendStringInfoString(&buf, ", \"scope\": ");
++ escape_json(&buf, ctx->scope);
++
++ appendStringInfoString(&buf, " }");
+
+ *output = buf.data;
+ *outputlen = buf.len;
6: 707edf9314 ! 6: fdbad1976a Introduce OAuth validator libraries
@@ src/backend/libpq/auth-oauth.c
#include "libpq/sasl.h"
#include "storage/fd.h"
+#include "storage/ipc.h"
+ #include "utils/json.h"
/* GUC */
-char *oauth_validator_command;
@@ src/backend/libpq/auth-oauth.c: validate(Port *port, const char *auth, const cha
+
+ /* Ensure that we have a correct token to validate */
+ if (!(token = validate_token_format(auth)))
- return false;
-
++ return false;
++
+ /* Call the validation function from the validator module */
+ ret = ValidatorCallbacks->validate_cb(validator_module_state,
+ token, port->user_name);
+
-+ if (!ret->authenticated)
-+ return false;
-+
++ if (!ret->authorized)
+ return false;
+
+ if (ret->authn_id)
+ set_authn_id(port, ret->authn_id);
+
@@ src/backend/libpq/auth-oauth.c: validate(Port *port, const char *auth, const cha
- ret = check_usermap(port->hba->usermap, port->user_name,
- MyClientConnectionInfo.authn_id, false);
- return (ret == STATUS_OK);
-+ map_status = check_usermap(port->hba->usermap, port->user_name,
-+ MyClientConnectionInfo.authn_id, false);
-+ return (map_status == STATUS_OK);
- }
-
+-}
+-
-static bool
-run_validator_command(Port *port, const char *token)
-{
@@ src/backend/libpq/auth-oauth.c: validate(Port *port, const char *auth, const cha
- 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;
-+ OAuthValidatorModuleInit validator_init;
-
+-
- rc = ClosePipeStream(*fh);
- *fh = NULL;
-
@@ src/backend/libpq/auth-oauth.c: validate(Port *port, const char *auth, const cha
- 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)));
--
-- pfree(reason);
-- }
+ if (OAuthValidatorLibrary[0] == '\0')
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("oauth_validator_library is not set")));
-- return (rc == 0);
--}
+- pfree(reason);
+- }
+ validator_init = (OAuthValidatorModuleInit)
+ load_external_function(OAuthValidatorLibrary,
+ "_PG_oauth_validator_module_init", false, NULL);
--static bool
--set_cloexec(int fd)
--{
-- int flags;
-- int rc;
+- 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)
- {
@@ src/backend/libpq/auth-oauth.c: validate(Port *port, const char *auth, const cha
- errmsg("could not get fd flags for child pipe: %m")));
- return false;
- }
-+ ValidatorCallbacks = (*validator_init) ();
-
-- 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;
-- }
+ 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);
}
@@ src/include/libpq/oauth.h
+
+typedef struct ValidatorModuleResult
+{
-+ bool authenticated;
++ bool authorized;
+ char *authn_id;
+} ValidatorModuleResult;
+
@@ src/test/modules/oauth_validator/t/001_server.pl (new)
+use PostgreSQL::Test::OAuthServer;
+use Test::More;
+
-+# Delete pg_hba.conf from the given node, add a new entry to it
-+# and then execute a reload to refresh it.
-+# XXX: this is copied from authentication/t/001_password and should be made
-+# generic functionality if we end up using it.
-+sub reset_pg_hba
-+{
-+ my $node = shift;
-+ my $database = shift;
-+ my $role = shift;
-+ my $hba_method = shift;
-+
-+ unlink($node->data_dir . '/pg_hba.conf');
-+ # just for testing purposes, use a continuation line
-+ $node->append_conf('pg_hba.conf',
-+ "local $database $role\\\n $hba_method");
-+ $node->reload;
-+ return;
-+}
-+
+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;
+
-+reset_pg_hba($node, 'all', 'all', 'oauth issuer="127.0.0.1:18080" scope="openid postgres"');
++$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);
+
@@ src/test/modules/oauth_validator/t/001_server.pl (new)
+$webserver->setup();
+$webserver->run();
+
-+$node->connect_ok("dbname=postgres oauth_client_id=f02c6361-0635", "connect",
++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();
@@ src/test/modules/oauth_validator/validator.c (new)
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
++#include "miscadmin.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
@@ src/test/modules/oauth_validator/validator.c (new)
+ return &validator_callbacks;
+}
+
++#define PRIVATE_COOKIE ((void *) 13579)
++
+static void
+validator_startup(ValidatorModuleState *state)
+{
-+ /* do nothing */
++ state->private_data = PRIVATE_COOKIE;
+}
+
+static void
@@ src/test/modules/oauth_validator/validator.c (new)
+{
+ 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, "XXX: validating %s for %s", token, role);
++ 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->authenticated = true;
++ res->authorized = true;
+ res->authn_id = pstrdup(role);
+
+ return res;
@@ src/test/perl/PostgreSQL/Test/OAuthServer.pm (new)
+ #}
+ #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": "http://localhost:$self->{'port'}",
-+ "token_endpoint": "http://localhost:$self->{'port'}/token",
-+ "device_authorization_endpoint": "http://localhost:$self->{'port'}/authorize",
++ "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"],
@@ src/test/perl/PostgreSQL/Test/OAuthServer.pm (new)
+ }
+ 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";
@@ src/test/perl/PostgreSQL/Test/OAuthServer.pm (new)
+ "device_code": "postgres",
+ "user_code" : "postgresuser",
+ "interval" : 0,
-+ "verification_uri" : "https://example.com/",
++ "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": "9243959234",
++ "access_token": "$token",
+ "token_type": "bearer"
+ }
+EOR
7: 47236c5644 ! 7: e1da97fb50 Add pytest suite for OAuth
@@ src/test/python/client/test_client.py (new)
+
+import pq3
+
++from .test_oauth import alt_patterns
++
+
+def finish_handshake(conn):
+ """
@@ src/test/python/client/test_client.py (new)
+ sock, client = accept()
+ sock.close()
+
-+ expected = "server closed the connection unexpectedly"
++ expected = alt_patterns(
++ "server closed the connection unexpectedly",
++ # On some platforms, ECONNABORTED gets set instead.
++ "Software caused connection abort",
++ )
+ with pytest.raises(psycopg2.OperationalError, match=expected):
+ client.check_completed()
+
@@ src/test/python/pq3.py (new)
+Pq3 = Struct(
+ "type" / types,
+ "len" / Default(Int32ub, lambda this: _payload_len(this) + 4),
-+ "payload" / FixedSized(this.len - 4, Default(_payload, b"")),
++ "payload"
++ / IfThenElse(
++ # Allow tests to explicitly pass an incorrect length during testing, by
++ # not enforcing a FixedSized during build. (The len calculation above
++ # defaults to the correct size.)
++ this._building,
++ Optional(_payload),
++ FixedSized(this.len - 4, Default(_payload, b"")),
++ ),
+)
+
+
@@ src/test/python/server/oauthtest.c (new)
+
+ if (reflect_role)
+ {
-+ res->authenticated = true;
++ res->authorized = true;
+ res->authn_id = pstrdup(role); /* TODO: constify? */
+ }
+ else
+ {
+ if (*expected_bearer && !strcmp(token, expected_bearer))
-+ res->authenticated = true;
++ res->authorized = true;
+ if (set_authn_id)
+ res->authn_id = authn_id;
+ }
@@ src/test/python/server/test_oauth.py (new)
+
+import psycopg2
+import pytest
++from construct import Container
+from psycopg2 import sql
+
+import pq3
@@ src/test/python/server/test_oauth.py (new)
+
+
+@contextlib.contextmanager
-+def prepend_file(path, lines):
++def prepend_file(path, lines, *, suffix=".bak"):
+ """
+ A context manager that prepends a file on disk with the desired lines of
+ text. When the context manager is exited, the file will be restored to its
+ original contents.
+ """
+ # First make a backup of the original file.
-+ bak = path + ".bak"
++ bak = path + suffix
+ shutil.copy2(path, bak)
+
+ try:
@@ src/test/python/server/test_oauth.py (new)
+ b"Bearer trailingtab\t",
+ b"Bearer me@example.com",
+ b"Beare abcd",
++ b" Bearer leadingspace",
+ b'OAuth realm="Example"',
+ b"",
+ ],
@@ src/test/python/server/test_oauth.py (new)
+ id="error response in initial message",
+ ),
+ pytest.param(
-+ pq3.types.PasswordMessage,
-+ b"x" * (MAX_SASL_MESSAGE_LENGTH + 1),
++ None,
++ # Sending an actual 65k packet results in ECONNRESET on Windows, and
++ # it floods the tests' connection log uselessly, so just fake the
++ # length and send a smaller number of bytes.
++ dict(
++ type=pq3.types.PasswordMessage,
++ len=MAX_SASL_MESSAGE_LENGTH + 1,
++ payload=b"x" * 512,
++ ),
+ ExpectedError(
+ INVALID_AUTHORIZATION_ERRCODE, "bearer authentication failed"
+ ),
@@ src/test/python/server/test_oauth.py (new)
+ ),
+ id="multiple auth values",
+ ),
++ pytest.param(
++ pq3.types.PasswordMessage,
++ pq3.SASLInitialResponse.build(
++ dict(
++ name=b"OAUTHBEARER",
++ data=b"y,,\x01=\x01\x01",
++ )
++ ),
++ ExpectedError(
++ PROTOCOL_VIOLATION_ERRCODE,
++ "malformed OAUTHBEARER message",
++ "empty key name",
++ ),
++ id="empty key",
++ ),
++ pytest.param(
++ pq3.types.PasswordMessage,
++ pq3.SASLInitialResponse.build(
++ dict(
++ name=b"OAUTHBEARER",
++ data=b"y,,\x01my key= \x01\x01",
++ )
++ ),
++ ExpectedError(
++ PROTOCOL_VIOLATION_ERRCODE,
++ "malformed OAUTHBEARER message",
++ "invalid key name",
++ ),
++ id="whitespace in key name",
++ ),
++ pytest.param(
++ pq3.types.PasswordMessage,
++ pq3.SASLInitialResponse.build(
++ dict(
++ name=b"OAUTHBEARER",
++ data=b"y,,\x01key=a\x05b\x01\x01",
++ )
++ ),
++ ExpectedError(
++ PROTOCOL_VIOLATION_ERRCODE,
++ "malformed OAUTHBEARER message",
++ "invalid value",
++ ),
++ id="junk in value",
++ ),
+ ],
+)
+def test_oauth_bad_initial_response(conn, oauth_ctx, type, payload, err):
+ begin_oauth_handshake(conn, oauth_ctx)
+
+ # The server expects a SASL response; give it something else instead.
-+ if not isinstance(payload, dict):
-+ payload = dict(payload_data=payload)
-+ pq3.send(conn, type, **payload)
++ if type is not None:
++ # Build a new packet of the desired type.
++ if not isinstance(payload, dict):
++ payload = dict(payload_data=payload)
++ pq3.send(conn, type, **payload)
++ else:
++ # The test has a custom packet to send. (The only reason to do this is
++ # if the packet is corrupt or otherwise unbuildable/unparsable, so we
++ # don't use the standard pq3.send().)
++ conn.write(pq3.Pq3.build(payload))
++ conn.end_packet(Container(payload))
+
+ resp = pq3.recv1(conn)
+ err.match(resp)
@@ src/test/python/server/test_oauth.py (new)
+ row = resp.payload
+ expected = b"oauth:" + username.encode("utf-8")
+ assert row.columns == [expected]
++
++
++@pytest.fixture
++def odd_oauth_ctx(postgres_instance, oauth_ctx):
++ """
++ Adds an HBA entry with messed up issuer/scope settings, to pin the server
++ behavior.
++
++ TODO: these should really be rejected in the HBA rather than passed through
++ by the server.
++ """
++ id = secrets.token_hex(4)
++
++ class Context:
++ user = oauth_ctx.user
++ dbname = oauth_ctx.dbname
++
++ # Both of these embedded double-quotes are invalid; they're prohibited
++ # in both URLs and OAuth scope identifiers.
++ issuer = oauth_ctx.issuer + '/"/'
++ scope = oauth_ctx.scope + ' quo"ted'
++
++ ctx = Context()
++ hba_issuer = ctx.issuer.replace('"', '""')
++ hba_scope = ctx.scope.replace('"', '""')
++ hba_lines = [
++ f'host {ctx.dbname} {ctx.user} samehost oauth issuer="{hba_issuer}" scope="{hba_scope}"\n',
++ ]
++
++ if platform.system() == "Windows":
++ # XXX why is 'samehost' not behaving as expected on Windows?
++ for l in list(hba_lines):
++ hba_lines.append(l.replace("samehost", "::1/128"))
++
++ host, port = postgres_instance
++ conn = psycopg2.connect(host=host, port=port)
++ conn.autocommit = True
++
++ with contextlib.closing(conn):
++ c = conn.cursor()
++
++ # Replace pg_hba. Note that it's already been replaced once by
++ # oauth_ctx, so use a different backup prefix in prepend_file().
++ c.execute("SHOW hba_file;")
++ hba = c.fetchone()[0]
++
++ with prepend_file(hba, hba_lines, suffix=".bak2"):
++ c.execute("SELECT pg_reload_conf();")
++
++ yield ctx
++
++ # Put things back the way they were.
++ c.execute("SELECT pg_reload_conf();")
++
++
++def test_odd_server_response(odd_oauth_ctx, connect):
++ """
++ Verifies that the server is correctly escaping the JSON in its failure
++ response.
++ """
++ conn = connect()
++ begin_oauth_handshake(conn, odd_oauth_ctx, user=odd_oauth_ctx.user)
++
++ # Send an empty auth initial response, which will force an authn failure.
++ send_initial_response(conn, auth=b"")
++
++ expect_handshake_failure(conn, odd_oauth_ctx)
## src/test/python/server/test_server.py (new) ##
@@
@@ src/test/python/test_pq3.py (new)
+ [
+ pytest.param(
+ dict(type=b"*", len=5),
-+ b"*\x00\x00\x00\x05\x00",
++ b"*\x00\x00\x00\x05",
+ id="type and len set explicitly",
+ ),
+ pytest.param(
@@ src/test/python/test_pq3.py (new)
+ id="implied len with payload",
+ ),
+ pytest.param(
++ dict(type=b"*", len=12, payload=b"1234"),
++ b"*\x00\x00\x00\x0C1234",
++ id="overridden len (payload underflow)",
++ ),
++ pytest.param(
++ dict(type=b"*", len=5, payload=b"1234"),
++ b"*\x00\x00\x00\x051234",
++ id="overridden len (payload overflow)",
++ ),
++ pytest.param(
+ dict(type=pq3.types.AuthnRequest, payload=dict(type=pq3.authn.OK)),
+ b"R\x00\x00\x00\x08\x00\x00\x00\x00",
+ id="implied len/type for AuthenticationOK",
8: 116e17eeee = 8: 185f9902fd XXX temporary patches to build and test
9: 28756eda1c ! 9: c4d850a7c4 WIP: Python OAuth provider implementation
@@ src/test/modules/oauth_validator/meson.build: tests += {
}
## src/test/modules/oauth_validator/t/001_server.pl ##
-@@ src/test/modules/oauth_validator/t/001_server.pl: $node->append_conf('postgresql.conf', "shared_preload_libraries = 'validator'\n"
- $node->append_conf('postgresql.conf', "oauth_validator_library = 'validator'\n");
- $node->start;
+@@ src/test/modules/oauth_validator/t/001_server.pl: $node->start;
+ $node->safe_psql('postgres', 'CREATE USER test;');
+ $node->safe_psql('postgres', 'CREATE USER testalt;');
--reset_pg_hba($node, 'all', 'all', 'oauth issuer="127.0.0.1:18080" scope="openid postgres"');
--
--my $webserver = PostgreSQL::Test::OAuthServer->new(18080);
+-my $issuer = "127.0.0.1:18080";
+my $webserver = PostgreSQL::Test::OAuthServer->new();
+$webserver->run();
++
++my $port = $webserver->port();
++my $issuer = "127.0.0.1:$port";
- my $port = $webserver->port();
+ unlink($node->data_dir . '/pg_hba.conf');
+ $node->append_conf('pg_hba.conf', qq{
+@@ src/test/modules/oauth_validator/t/001_server.pl: 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();
-+reset_pg_hba($node, 'all', 'all', 'oauth issuer="127.0.0.1:' . $port . '" scope="openid postgres"');
+-
+ my ($log_start, $log_end);
+ $log_start = $node->wait_for_log(qr/reloading configuration files/);
- $node->connect_ok("dbname=postgres oauth_client_id=f02c6361-0635", "connect",
- expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@);
+@@ src/test/modules/oauth_validator/t/001_server.pl: $node->log_check("user $user: validator sets authenticated identity", $log_start
+ ]);
+ $log_start = $log_end;
+$webserver->stop();
$node->stop;
@@ src/test/modules/oauth_validator/t/oauth_server.py (new)
+class OAuthHandler(http.server.BaseHTTPRequestHandler):
+ JsonObject = dict[str, object] # TypeAlias is not available until 3.10
+
++ def _check_issuer(self):
++ """
++ Switches the behavior of the provider depending on the issuer URI.
++ """
++ self._alt_issuer = self.path.startswith("/alternate/")
++ if self._alt_issuer:
++ self.path = self.path.removeprefix("/alternate")
++
+ def do_GET(self):
++ self._check_issuer()
++
+ if self.path == "/.well-known/openid-configuration":
+ resp = self.config()
+ else:
@@ src/test/modules/oauth_validator/t/oauth_server.py (new)
+ self._send_json(resp)
+
+ def do_POST(self):
++ self._check_issuer()
++
+ if self.path == "/authorize":
+ resp = self.authorization()
+ elif self.path == "/token":
@@ src/test/modules/oauth_validator/t/oauth_server.py (new)
+
+ def config(self) -> JsonObject:
+ port = self.server.socket.getsockname()[1]
++ issuer = f"http://localhost:{port}"
++ if self._alt_issuer:
++ issuer += "/alternate"
+
+ return {
-+ "issuer": f"http://localhost:{port}",
-+ "token_endpoint": f"http://localhost:{port}/token",
-+ "device_authorization_endpoint": f"http://localhost:{port}/authorize",
++ "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"],
@@ src/test/modules/oauth_validator/t/oauth_server.py (new)
+ }
+
+ def authorization(self) -> JsonObject:
++ uri = "https://example.com/"
++ if self._alt_issuer:
++ uri = "https://example.org/"
++
+ return {
+ "device_code": "postgres",
+ "user_code": "postgresuser",
+ "interval": 0,
-+ "verification_uri": "https://example.com/",
++ "verification_uri": uri,
+ "expires-in": 5,
+ }
+
+ def token(self) -> JsonObject:
++ token = "9243959234"
++ if self._alt_issuer:
++ token += "-alt"
++
+ return {
-+ "access_token": "9243959234",
++ "access_token": token,
+ "token_type": "bearer",
+ }
+
@@ src/test/perl/PostgreSQL/Test/OAuthServer.pm: sub port
- #}
- #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": "http://localhost:$self->{'port'}",
-- "token_endpoint": "http://localhost:$self->{'port'}/token",
-- "device_authorization_endpoint": "http://localhost:$self->{'port'}/authorize",
+- "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"],
@@ src/test/perl/PostgreSQL/Test/OAuthServer.pm: sub port
- }
- 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";
@@ src/test/perl/PostgreSQL/Test/OAuthServer.pm: sub port
- "device_code": "postgres",
- "user_code" : "postgresuser",
- "interval" : 0,
-- "verification_uri" : "https://example.com/",
+- "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": "9243959234",
+- "access_token": "$token",
- "token_type": "bearer"
- }
-EOR