since-v19.diff.txt

text/plain

Filename: since-v19.diff.txt
Type: text/plain
Part: 0
Message: Re: [PoC] Federated Authn/z with OAUTHBEARER
 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