since-v36.diff.txt

text/plain

Filename: since-v36.diff.txt
Type: text/plain
Part: 0
Message: Re: [PoC] Federated Authn/z with OAUTHBEARER
1:  5730b875b8 ! 1:  16f1b8fc02 Add OAUTHBEARER SASL mechanism
    @@ doc/src/sgml/client-auth.sgml: host ... radius radiusservers="server1,server2" r
     +      <term><literal>issuer</literal></term>
     +      <listitem>
     +       <para>
    -+        The <acronym>URL</acronym> of the OAuth issuing party, which the client
    -+        must contact to receive a bearer token.  This parameter is required.
    ++        The issuer identifier of the authorization server, as defined by its
    ++        discovery document, or a well-known URI pointing to that discovery
    ++        document. This parameter is required.
     +       </para>
    ++       <para>
    ++        When an OAuth client connects to the server, a discovery document URI
    ++        will be constructed using the issuer identifier. By default, the URI
    ++        uses the conventions of OpenID Connect Discovery: the path
    ++        <literal>/.well-known/openid-configuration</literal> will be appended
    ++        to the issuer identifier. Alternatively, if the
    ++        <literal>issuer</literal> contains a <literal>/.well-known/</literal>
    ++        path segment, the URI will be provided to the client as-is.
    ++       </para>
    ++       <warning>
    ++        <para>
    ++         The OAuth client in libpq requires the server's issuer setting to
    ++         exactly match the issuer identifier which is provided in the discovery
    ++         document, which must in turn match the client's
    ++         <xref linkend="libpq-connect-oauth-issuer"/> setting. No variations in
    ++         case or format are permitted.
    ++        </para>
    ++       </warning>
     +      </listitem>
     +     </varlistentry>
     +
    @@ doc/src/sgml/client-auth.sgml: host ... radius radiusservers="server1,server2" r
     +     </varlistentry>
     +
     +     <varlistentry>
    ++      <term><literal>validator</literal></term>
    ++      <listitem>
    ++       <para>
    ++        The library to use for validating bearer tokens. If given, the name must
    ++        exactly match one of the libraries listed in
    ++        <xref linkend="guc-oauth-validator-libraries" />.  This parameter is
    ++        optional unless <literal>oauth_validator_libraries</literal> contains
    ++        more than one library, in which case it is required.
    ++       </para>
    ++      </listitem>
    ++     </varlistentry>
    ++
    ++     <varlistentry>
     +      <term><literal>map</literal></term>
     +      <listitem>
     +       <para>
    @@ doc/src/sgml/config.sgml: include_dir 'conf.d'
            </listitem>
           </varlistentry>
     +
    -+     <varlistentry id="guc-oauth-validator-library" xreflabel="oauth_validator_library">
    -+      <term><varname>oauth_validator_library</varname> (<type>string</type>)
    ++     <varlistentry id="guc-oauth-validator-libraries" xreflabel="oauth_validator_libraries">
    ++      <term><varname>oauth_validator_libraries</varname> (<type>string</type>)
     +      <indexterm>
    -+       <primary><varname>oauth_validator_library</varname> configuration parameter</primary>
    ++       <primary><varname>oauth_validator_libraries</varname> configuration parameter</primary>
     +      </indexterm>
     +      </term>
     +      <listitem>
     +       <para>
    -+        The library to use for validating OAuth connection tokens. If set to
    -+        an empty string (the default), OAuth connections will be refused. For
    -+        more information on implementing OAuth validators see
    ++        The library/libraries to use for validating OAuth connection tokens. If
    ++        only one validator library is provided, it will be used by default for
    ++        any OAuth connections; otherwise, all
    ++        <link linkend="auth-oauth"><literal>oauth</literal> HBA entries</link>
    ++        must explicitly set a <literal>validator</literal> chosen from this
    ++        list. If set to an empty string (the default), OAuth connections will be
    ++        refused. For more information on implementing OAuth validators see
     +        <xref linkend="oauth-validators" />. This parameter can only be set in
     +        the <filename>postgresql.conf</filename> file.
     +       </para>
    @@ doc/src/sgml/libpq.sgml: postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
            </listitem>
           </varlistentry>
     +
    ++     <varlistentry id="libpq-connect-oauth-issuer" xreflabel="oauth_issuer">
    ++      <term><literal>oauth_issuer</literal></term>
    ++      <listitem>
    ++       <para>
    ++        The HTTPS URL of an issuer to contact if the server requests an OAuth
    ++        token for the connection. This parameter is required for all OAuth
    ++        connections; it should exactly match the <literal>issuer</literal>
    ++        setting in <link linkend="auth-oauth">the server's HBA configuration.</link>
    ++       </para>
    ++       <para>
    ++        As part of the standard authentication handshake, libpq will ask the
    ++        server for a <emphasis>discovery document:</emphasis> a URI providing a
    ++        set of OAuth configuration parameters. The server must provide a URI
    ++        that is directly constructed from the components of the
    ++        <literal>oauth_issuer</literal>, and this value must exactly match the
    ++        issuer identifier that is declared in the discovery document itself, or
    ++        the connection will fail. This is required to prevent a class of "mix-up
    ++        attacks" on OAuth clients.
    ++       </para>
    ++       <para>
    ++        This standard handshake requires two separate network connections to the
    ++        server per authentication attempt. To skip asking the server for a
    ++        discovery document URI, you may set <literal>oauth_issuer</literal> to a
    ++        <literal>/.well-known/</literal> URI used for OAuth discovery. (In this
    ++        case, it is recommended that
    ++        <xref linkend="libpq-connect-oauth-scope"/> be set as well, since the
    ++        client will not have a chance to ask the server for a correct scope
    ++        setting, and the default scopes for a token may not be sufficient to
    ++        connect.) libpq currently supports the following well-known endpoints:
    ++        <itemizedlist>
    ++         <listitem><para><literal>/.well-known/openid-configuration</literal></para></listitem>
    ++         <listitem><para><literal>/.well-known/oauth-authorization-server</literal></para></listitem>
    ++        </itemizedlist>
    ++       </para>
    ++      </listitem>
    ++     </varlistentry>
    ++
     +     <varlistentry id="libpq-connect-oauth-client-id" xreflabel="oauth_client_id">
     +      <term><literal>oauth_client_id</literal></term>
     +      <listitem>
    @@ doc/src/sgml/libpq.sgml: postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
     +      </listitem>
     +     </varlistentry>
     +
    -+     <varlistentry id="libpq-connect-oauth-issuer" xreflabel="oauth_issuer">
    -+      <term><literal>oauth_issuer</literal></term>
    -+      <listitem>
    -+       <para>
    -+        The HTTPS URL of an issuer to contact if the server requests an OAuth
    -+        token for the connection. This parameter is optional and intended for
    -+        advanced usage; see also <xref linkend="libpq-connect-oauth-scope"/>.
    -+       </para>
    -+       <para>
    -+        If no <literal>oauth_issuer</literal> is provided, the client will ask
    -+        the <productname>PostgreSQL</productname> server to provide an
    -+        acceptable issuer URL (as configured in its
    -+        <link linkend="auth-oauth">HBA settings</link>). This is convenient, but
    -+        it requires two separate network connections to the server per attempt.
    -+       </para>
    -+       <para>
    -+        Providing an explicit <literal>oauth_issuer</literal> (and, typically,
    -+        an accompanying <literal>oauth_scope</literal>) skips this initial
    -+        "discovery" phase, which may speed up certain custom OAuth flows.
    -+        <!-- TODO: note to reviewer: the following is only partially implemented. -->
    -+        This parameter may also be set defensively, to prevent the backend
    -+        server from directing the client to arbitrary URLs.
    -+        <emphasis>However:</emphasis> if the client's issuer setting differs
    -+        from the server's expected issuer, the server is likely to reject the
    -+        issued token, and the connection will fail.
    -+       </para>
    -+      </listitem>
    -+     </varlistentry>
    -+
     +     <varlistentry id="libpq-connect-oauth-scope" xreflabel="oauth_scope">
     +      <term><literal>oauth_scope</literal></term>
     +      <listitem>
    @@ doc/src/sgml/libpq.sgml: postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
     +        identifiers. This parameter is optional and intended for advanced usage.
     +       </para>
     +       <para>
    -+        <!-- TODO: note to reviewer: the following is only partially implemented. -->
    -+        If neither <xref linkend="libpq-connect-oauth-issuer"/> nor
    -+        <literal>oauth_scope</literal> is specified, the client will obtain
    -+        appropriate scope settings from the
    -+        <productname>PostgreSQL</productname> server. Otherwise, the value of
    -+        this parameter will be used. Similarly to
    -+        <literal>oauth_issuer</literal>, if the client's scope setting does not
    -+        contain the server's required scopes, the server is likely to reject the
    -+        issued token, and the connection will fail.
    ++        Usually the client will obtain appropriate scope settings from the
    ++        <productname>PostgreSQL</productname> server. If this parameter is used,
    ++        the server's requested scope list will be ignored. This can prevent a
    ++        less-trusted server from requesting inappropriate access scopes from the
    ++        end user. However, if the client's scope setting does not contain the
    ++        server's required scopes, the server is likely to reject the issued
    ++        token, and the connection will fail.
     +       </para>
     +       <para>
     +        The meaning of an empty scope list is provider-dependent. An OAuth
    @@ doc/src/sgml/oauth-validators.sgml (new)
     +   <primary>_PG_oauth_validator_module_init</primary>
     +  </indexterm>
     +  <para>
    -+   An OAuth validator module is loaded by dynamically loading a shared library
    -+   with the <xref linkend="guc-oauth-validator-library"/>'s name as the library
    -+   base name. The normal library search path is used to locate the library. To
    ++   An OAuth validator module is loaded by dynamically loading one of the shared
    ++   libraries listed in <xref linkend="guc-oauth-validator-libraries"/>.
    ++   The normal library search path is used to locate the library. To
     +   provide the validator callbacks and to indicate that the library is an OAuth
     +   validator module a function named
     +   <function>_PG_oauth_validator_module_init</function> must be provided. The
    @@ src/backend/libpq/auth-oauth.c (new)
     +#include "storage/fd.h"
     +#include "storage/ipc.h"
     +#include "utils/json.h"
    ++#include "utils/varlena.h"
     +
     +/* GUC */
    -+char	   *OAuthValidatorLibrary = "";
    ++char	   *oauth_validator_libraries_string = NULL;
     +
     +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 load_validator_library(const char *libname);
     +static void shutdown_validator_library(int code, Datum arg);
     +
     +static ValidatorModuleState *validator_module_state;
    @@ src/backend/libpq/auth-oauth.c (new)
     +	ctx->issuer = port->hba->oauth_issuer;
     +	ctx->scope = port->hba->oauth_scope;
     +
    -+	load_validator_library();
    ++	load_validator_library(port->hba->oauth_validator);
     +
     +	return ctx;
     +}
    @@ 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.
    ++	/*
    ++	 * Build a default .well-known URI based on our issuer, unless the HBA has
    ++	 * already provided one.
     +	 */
     +	initStringInfo(&issuer);
     +	appendStringInfoString(&issuer, ctx->issuer);
    -+	appendStringInfoString(&issuer, "/.well-known/openid-configuration");
    ++	if (strstr(ctx->issuer, "/.well-known/") == NULL)
    ++		appendStringInfoString(&issuer, "/.well-known/openid-configuration");
     +
     +	initStringInfo(&buf);
     +
    @@ src/backend/libpq/auth-oauth.c (new)
     + * since token validation won't be possible.
     + */
     +static void
    -+load_validator_library(void)
    ++load_validator_library(const char *libname)
     +{
     +	OAuthValidatorModuleInit validator_init;
     +
    -+	if (OAuthValidatorLibrary[0] == '\0')
    -+		ereport(ERROR,
    -+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
    -+				errmsg("oauth_validator_library is not set"));
    ++	Assert(libname && *libname);
     +
     +	validator_init = (OAuthValidatorModuleInit)
    -+		load_external_function(OAuthValidatorLibrary,
    -+							   "_PG_oauth_validator_module_init", false, NULL);
    ++		load_external_function(libname, "_PG_oauth_validator_module_init",
    ++							   false, NULL);
     +
     +	/*
     +	 * The validator init function is required since it will set the callbacks
    @@ src/backend/libpq/auth-oauth.c (new)
     +	 */
     +	if (validator_init == NULL)
     +		ereport(ERROR,
    -+				errmsg("%s modules \"%s\" have to define the symbol %s",
    -+					   "OAuth validator", OAuthValidatorLibrary, "_PG_oauth_validator_module_init"));
    ++				errmsg("%s module \"%s\" must define the symbol %s",
    ++					   "OAuth validator", libname, "_PG_oauth_validator_module_init"));
     +
     +	ValidatorCallbacks = (*validator_init) ();
     +
    @@ src/backend/libpq/auth-oauth.c (new)
     +{
     +	if (ValidatorCallbacks->shutdown_cb != NULL)
     +		ValidatorCallbacks->shutdown_cb(validator_module_state);
    ++}
    ++
    ++/*
    ++ * Ensure an OAuth validator named in the HBA is permitted by the configuration.
    ++ *
    ++ * If the validator is currently unset and exactly one library is declared in
    ++ * oauth_validator_libraries, then that library will be used as the validator.
    ++ * Otherwise the name must be present in the list of oauth_validator_libraries.
    ++ */
    ++bool
    ++check_oauth_validator(HbaLine *hbaline, int elevel, char **err_msg)
    ++{
    ++	int			line_num = hbaline->linenumber;
    ++	char	   *file_name = hbaline->sourcefile;
    ++	char	   *rawstring;
    ++	List	   *elemlist = NIL;
    ++	ListCell   *l;
    ++
    ++	*err_msg = NULL;
    ++
    ++	if (oauth_validator_libraries_string[0] == '\0')
    ++	{
    ++		ereport(elevel,
    ++				errcode(ERRCODE_CONFIG_FILE_ERROR),
    ++				errmsg("oauth_validator_libraries must be set for authentication method %s",
    ++					   "oauth"),
    ++				errcontext("line %d of configuration file \"%s\"",
    ++						   line_num, file_name));
    ++		*err_msg = psprintf("oauth_validator_libraries must be set for authentication method %s",
    ++							"oauth");
    ++		return false;
    ++	}
    ++
    ++	/* SplitDirectoriesString needs a modifiable copy */
    ++	rawstring = pstrdup(oauth_validator_libraries_string);
    ++
    ++	if (!SplitDirectoriesString(rawstring, ',', &elemlist))
    ++	{
    ++		/* syntax error in list */
    ++		ereport(elevel,
    ++				errcode(ERRCODE_CONFIG_FILE_ERROR),
    ++				errmsg("invalid list syntax in parameter \"%s\"",
    ++					   "oauth_validator_libraries"));
    ++		*err_msg = psprintf("invalid list syntax in parameter \"%s\"",
    ++							"oauth_validator_libraries");
    ++		goto done;
    ++	}
    ++
    ++	if (!hbaline->oauth_validator)
    ++	{
    ++		if (elemlist->length == 1)
    ++		{
    ++			hbaline->oauth_validator = pstrdup(linitial(elemlist));
    ++			goto done;
    ++		}
    ++
    ++		ereport(elevel,
    ++				errcode(ERRCODE_CONFIG_FILE_ERROR),
    ++				errmsg("authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options"),
    ++				errcontext("line %d of configuration file \"%s\"",
    ++						   line_num, file_name));
    ++		*err_msg = "authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options";
    ++		goto done;
    ++	}
    ++
    ++	foreach(l, elemlist)
    ++	{
    ++		char	   *allowed = lfirst(l);
    ++
    ++		if (strcmp(allowed, hbaline->oauth_validator) == 0)
    ++			goto done;
    ++	}
    ++
    ++	ereport(elevel,
    ++			errcode(ERRCODE_INVALID_PARAMETER_VALUE),
    ++			errmsg("validator \"%s\" is not permitted by %s",
    ++				   hbaline->oauth_validator, "oauth_validator_libraries"),
    ++			errcontext("line %d of configuration file \"%s\"",
    ++					   line_num, file_name));
    ++	*err_msg = psprintf("validator \"%s\" is not permitted by %s",
    ++						hbaline->oauth_validator, "oauth_validator_libraries");
    ++
    ++done:
    ++	list_free_deep(elemlist);
    ++	pfree(rawstring);
    ++
    ++	return (*err_msg == NULL);
     +}
     
      ## src/backend/libpq/auth.c ##
    @@ src/backend/libpq/auth.c: ClientAuthentication(Port *port)
      	if ((status == STATUS_OK && port->hba->clientcert == clientCertFull)
     
      ## src/backend/libpq/hba.c ##
    +@@
    + #include "libpq/hba.h"
    + #include "libpq/ifaddr.h"
    + #include "libpq/libpq-be.h"
    ++#include "libpq/oauth.h"
    + #include "postmaster/postmaster.h"
    + #include "regex/regex.h"
    + #include "replication/walsender.h"
     @@ src/backend/libpq/hba.c: static const char *const UserAuthName[] =
      	"ldap",
      	"cert",
    @@ src/backend/libpq/hba.c: parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
     +		MANDATORY_AUTH_ARG(parsedline->oauth_scope, "scope", "oauth");
     +		MANDATORY_AUTH_ARG(parsedline->oauth_issuer, "issuer", "oauth");
     +
    ++		/* Ensure a validator library is set and permitted by the config. */
    ++		if (!check_oauth_validator(parsedline, elevel, err_msg))
    ++			return NULL;
    ++
     +		/*
     +		 * Supplying a usermap combined with the option to skip usermapping is
     +		 * nonsensical and indicates a configuration error.
    @@ src/backend/libpq/hba.c: parse_hba_auth_opt(char *name, char *val, HbaLine *hbal
     +		REQUIRE_AUTH_OPTION(uaOAuth, "scope", "oauth");
     +		hbaline->oauth_scope = pstrdup(val);
     +	}
    ++	else if (strcmp(name, "validator") == 0)
    ++	{
    ++		REQUIRE_AUTH_OPTION(uaOAuth, "validator", "oauth");
    ++		hbaline->oauth_validator = pstrdup(val);
    ++	}
     +	else if (strcmp(name, "trust_validator_authz") == 0)
     +	{
     +		REQUIRE_AUTH_OPTION(uaOAuth, "trust_validator_authz", "oauth");
    @@ src/backend/utils/misc/guc_tables.c: struct config_string ConfigureNamesString[]
      	},
      
     +	{
    -+		{"oauth_validator_library", PGC_SIGHUP, CONN_AUTH_AUTH,
    -+			gettext_noop("Sets the library that will be called to validate OAuth v2 bearer tokens."),
    ++		{"oauth_validator_libraries", PGC_SIGHUP, CONN_AUTH_AUTH,
    ++			gettext_noop("Lists libraries that may be called to validate OAuth v2 bearer tokens."),
     +			NULL,
    -+			GUC_SUPERUSER_ONLY | GUC_NOT_IN_SAMPLE
    ++			GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY
     +		},
    -+		&OAuthValidatorLibrary,
    ++		&oauth_validator_libraries_string,
     +		"",
     +		NULL, NULL, NULL
     +	},
    @@ src/backend/utils/misc/guc_tables.c: struct config_string ConfigureNamesString[]
      	{
      		{NULL, 0, 0, NULL, NULL}, NULL, NULL, NULL, NULL, NULL
     
    + ## src/backend/utils/misc/postgresql.conf.sample ##
    +@@
    + #ssl_passphrase_command = ''
    + #ssl_passphrase_command_supports_reload = off
    + 
    ++# OAuth
    ++#oauth_validator_libraries = ''
    ++
    + 
    + #------------------------------------------------------------------------------
    + # RESOURCE USAGE (except WAL)
    +
      ## src/include/common/oauth-common.h (new) ##
     @@
     +/*-------------------------------------------------------------------------
    @@ src/include/libpq/hba.h: typedef struct HbaLine
      	char	   *radiusports_s;
     +	char	   *oauth_issuer;
     +	char	   *oauth_scope;
    ++	char	   *oauth_validator;
     +	bool		oauth_skip_usermap;
      } HbaLine;
      
    @@ src/include/libpq/oauth.h (new)
     +#include "libpq/libpq-be.h"
     +#include "libpq/sasl.h"
     +
    -+extern PGDLLIMPORT char *OAuthValidatorLibrary;
    ++extern PGDLLIMPORT char *oauth_validator_libraries_string;
     +
     +typedef struct ValidatorModuleState
     +{
    @@ src/include/libpq/oauth.h (new)
     +/* Implementation */
     +extern const pg_be_sasl_mech pg_be_oauth_mech;
     +
    ++/*
    ++ * Ensure a validator named in the HBA is permitted by the configuration.
    ++ */
    ++extern bool check_oauth_validator(HbaLine *hba, int elevel, char **err_msg);
    ++
     +#endif							/* PG_OAUTH_H */
     
      ## src/include/pg_config.h.in ##
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		CHECK_SETOPT(actx, popt, protos, return false);
     +	}
     +
    ++	/* TODO: would anyone use this in "real" situations, or just testing? */
    ++	if (actx->debugging)
    ++	{
    ++		const char *env;
    ++
    ++		if ((env = getenv("PGOAUTHCAFILE")) != NULL)
    ++			CHECK_SETOPT(actx, CURLOPT_CAINFO, env, return false);
    ++	}
    ++
     +	/*
     +	 * Suppress the Accept header to make our request as minimal as possible.
     +	 * (Ideally we would set it to "application/json" instead, but OpenID is
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +
     +	if (actx->running)
     +	{
    -+		/*
    ++		/*---
     +		 * There's an async request in progress. Pump the multi handle.
     +		 *
    -+		 * TODO: curl_multi_socket_all() is deprecated, presumably because
    -+		 * it's inefficient and pointless if your event loop has already
    -+		 * handed you the exact sockets that are ready. But that's not our use
    -+		 * case -- our client has no way to tell us which sockets are ready.
    -+		 * (They don't even know there are sockets to begin with.)
    ++		 * curl_multi_socket_all() is officially deprecated, because it's
    ++		 * inefficient and pointless if your event loop has already handed you
    ++		 * the exact sockets that are ready. But that's not our use case --
    ++		 * our client has no way to tell us which sockets are ready. (They
    ++		 * don't even know there are sockets to begin with.)
     +		 *
     +		 * We can grab the list of triggered events from the multiplexer
     +		 * ourselves, but that's effectively what curl_multi_socket_all() is
    -+		 * going to do... so it appears to be exactly the API we need.
    ++		 * going to do. And there are currently no plans for the Curl project
    ++		 * to remove or break this API, so ignore the deprecation. See
    ++		 *
    ++		 *    https://curl.se/mail/lib-2024-11/0028.html
     +		 *
    -+		 * Ignore the deprecation for now. This needs a followup on
    -+		 * curl-library@, to make sure we're not shooting ourselves in the
    -+		 * foot in some other way.
     +		 */
     +		CURL_IGNORE_DEPRECATION(
     +			err = curl_multi_socket_all(actx->curlm, &actx->running);
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	return true;
     +}
     +
    ++/*
    ++ * Ensure that the discovery document is provided by the expected issuer.
    ++ * Currently, issuers are statically configured in the connection string.
    ++ */
    ++static bool
    ++check_issuer(struct async_ctx *actx, PGconn *conn)
    ++{
    ++	const struct provider *provider = &actx->provider;
    ++
    ++	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
    ++	Assert(provider->issuer);	/* ensured by parse_provider() */
    ++
    ++	/*---
    ++	 * We require strict equality for issuer identifiers -- no path or case
    ++	 * normalization, no substitution of default ports and schemes, etc. This
    ++	 * is done to match the rules in OIDC Discovery Sec. 4.3 for config
    ++	 * validation:
    ++	 *
    ++	 *    The issuer value returned MUST be identical to the Issuer URL that
    ++	 *    was used as the prefix to /.well-known/openid-configuration to
    ++	 *    retrieve the configuration information.
    ++	 *
    ++	 * as well as the rules set out in RFC 9207 for avoiding mix-up attacks:
    ++	 *
    ++	 *    Clients MUST then [...] compare the result to the issuer identifier
    ++	 *    of the authorization server where the authorization request was
    ++	 *    sent to. This comparison MUST use simple string comparison as defined
    ++	 *    in Section 6.2.1 of [RFC3986].
    ++	 *
    ++	 * TODO: Encoding support?
    ++	 */
    ++	if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0)
    ++	{
    ++		actx_error(actx,
    ++				   "the issuer identifier (%s) does not match oauth_issuer (%s)",
    ++				   provider->issuer, conn->oauth_issuer_id);
    ++		return false;
    ++	}
    ++
    ++	return true;
    ++}
    ++
     +#define OAUTH_GRANT_TYPE_DEVICE_CODE "urn:ietf:params:oauth:grant-type:device_code"
     +
     +/*
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	const struct curl_slist *grant;
     +	bool		device_grant_found = false;
     +
    -+	Assert(provider->issuer);	/* ensured by get_discovery_document() */
    ++	Assert(provider->issuer);	/* ensured by parse_provider() */
     +
     +	/*------
     +	 * First, sanity checks for discovery contents that are OPTIONAL in the
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	const char *device_authz_uri = actx->provider.device_authorization_endpoint;
     +	PQExpBuffer work_buffer = &actx->work_data;
     +
    -+	Assert(conn->oauth_client_id);	/* ensured by get_auth_token() */
    ++	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
     +	Assert(device_authz_uri);	/* ensured by check_for_device_flow() */
     +
     +	/* Construct our request body. */
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	const char *device_code = actx->authz.device_code;
     +	PQExpBuffer work_buffer = &actx->work_data;
     +
    -+	Assert(conn->oauth_client_id);	/* ensured by get_auth_token() */
    -+	Assert(token_uri);			/* ensured by get_discovery_document() */
    -+	Assert(device_code);		/* ensured by run_device_authz() */
    ++	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
    ++	Assert(token_uri);			/* ensured by parse_provider() */
    ++	Assert(device_code);		/* ensured by parse_device_authz() */
     +
     +	/* Construct our request body. */
     +	resetPQExpBuffer(work_buffer);
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +
     +	if (!state->async_ctx)
     +	{
    -+		const char *env;
    -+
     +		/*
     +		 * Create our asynchronous state, and hook it into the upper-level
     +		 * OAuth state immediately, so any failures below won't leak the
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +#endif
     +
     +		/* Should we enable unsafe features? */
    -+		env = getenv("PGOAUTHDEBUG");
    -+		if (env && strcmp(env, "UNSAFE") == 0)
    -+			actx->debugging = true;
    ++		actx->debugging = oauth_unsafe_debugging_enabled();
     +
     +		state->async_ctx = actx;
     +		state->free_async_ctx = free_curl_async_ctx;
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +				if (!finish_discovery(actx))
     +					goto error_return;
     +
    -+				/* TODO: check issuer */
    ++				if (!check_issuer(actx, conn))
    ++					goto error_return;
     +
     +				actx->errctx = "cannot run OAuth device authorization";
     +				if (!check_for_device_flow(actx))
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +			 * TODO: users shouldn't see this; what action should they take if
     +			 * they do?
     +			 */
    -+			libpq_append_conn_error(conn, "no OAuth token was set for the connection");
    ++			libpq_append_conn_error(conn,
    ++									"no OAuth token was set for the connection");
     +			return NULL;
     +		}
     +	}
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
     +}
     +
    ++#define HTTPS_SCHEME "https://"
    ++#define HTTP_SCHEME "http://"
    ++
    ++/* We support both well-known suffixes defined by RFC 8414. */
    ++#define WK_PREFIX "/.well-known/"
    ++#define OPENID_WK_SUFFIX "openid-configuration"
    ++#define OAUTH_WK_SUFFIX "oauth-authorization-server"
    ++
    ++/*
    ++ * Derives an issuer identifier from one of our recognized .well-known URIs,
    ++ * using the rules in RFC 8414.
    ++ */
    ++static char *
    ++issuer_from_well_known_uri(PGconn *conn, const char *wkuri)
    ++{
    ++	const char *authority_start = NULL;
    ++	const char *wk_start;
    ++	const char *wk_end;
    ++	char	   *issuer;
    ++	ptrdiff_t	start_offset,
    ++				end_offset;
    ++	size_t		end_len;
    ++
    ++	/*
    ++	 * https:// is required for issuer identifiers (RFC 8414, Sec. 2; OIDC
    ++	 * Discovery 1.0, Sec. 3). This is a case-insensitive comparison at this
    ++	 * level (but issuer identifier comparison at the level above this is
    ++	 * case-sensitive, so in practice it's probably moot).
    ++	 */
    ++	if (pg_strncasecmp(wkuri, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0)
    ++		authority_start = wkuri + strlen(HTTPS_SCHEME);
    ++
    ++	if (!authority_start
    ++		&& oauth_unsafe_debugging_enabled()
    ++		&& pg_strncasecmp(wkuri, HTTP_SCHEME, strlen(HTTP_SCHEME)) == 0)
    ++	{
    ++		/* Allow http:// for testing only. */
    ++		authority_start = wkuri + strlen(HTTP_SCHEME);
    ++	}
    ++
    ++	if (!authority_start)
    ++	{
    ++		libpq_append_conn_error(conn,
    ++								"OAuth discovery URI \"%s\" must use HTTPS",
    ++								wkuri);
    ++		return NULL;
    ++	}
    ++
    ++	/*
    ++	 * Well-known URIs in general may support queries and fragments, but the
    ++	 * two types we support here do not. (They must be constructed from the
    ++	 * components of issuer identifiers, which themselves may not contain any
    ++	 * queries or fragments.)
    ++	 *
    ++	 * It's important to check this first, to avoid getting tricked later by a
    ++	 * prefix buried inside a query or fragment.
    ++	 */
    ++	if (strpbrk(authority_start, "?#") != NULL)
    ++	{
    ++		libpq_append_conn_error(conn,
    ++								"OAuth discovery URI \"%s\" must not contain query or fragment components",
    ++								wkuri);
    ++		return NULL;
    ++	}
    ++
    ++	/*
    ++	 * Find the start of the .well-known prefix. IETF rules state this must be
    ++	 * at the beginning of the path component, but OIDC defined it at the end
    ++	 * instead, so we have to search for it anywhere.
    ++	 */
    ++	wk_start = strstr(authority_start, WK_PREFIX);
    ++	if (!wk_start)
    ++	{
    ++		libpq_append_conn_error(conn,
    ++								"OAuth discovery URI \"%s\" is not a .well-known URI",
    ++								wkuri);
    ++		return NULL;
    ++	}
    ++
    ++	/*
    ++	 * Now find the suffix type. We only support the two defined in OIDC
    ++	 * Discovery 1.0 and RFC 8414.
    ++	 */
    ++	wk_end = wk_start + strlen(WK_PREFIX);
    ++
    ++	if (strncmp(wk_end, OPENID_WK_SUFFIX, strlen(OPENID_WK_SUFFIX)) == 0)
    ++		wk_end += strlen(OPENID_WK_SUFFIX);
    ++	else if (strncmp(wk_end, OAUTH_WK_SUFFIX, strlen(OAUTH_WK_SUFFIX)) == 0)
    ++		wk_end += strlen(OAUTH_WK_SUFFIX);
    ++	else
    ++		wk_end = NULL;
    ++
    ++	/*
    ++	 * Even if there's a match, we still need to check to make sure the suffix
    ++	 * takes up the entire path segment, to weed out constructions like
    ++	 * "/.well-known/openid-configuration-bad".
    ++	 */
    ++	if (!wk_end || (*wk_end != '/' && *wk_end != '\0'))
    ++	{
    ++		libpq_append_conn_error(conn,
    ++								"OAuth discovery URI \"%s\" uses an unsupported .well-known suffix",
    ++								wkuri);
    ++		return NULL;
    ++	}
    ++
    ++	/*
    ++	 * Finally, make sure the .well-known components are provided either as a
    ++	 * prefix (IETF style) or as a postfix (OIDC style). In other words,
    ++	 * "https://localhost/a/.well-known/openid-configuration/b" is not allowed
    ++	 * to claim association with "https://localhost/a/b".
    ++	 */
    ++	if (*wk_end != '\0')
    ++	{
    ++		/*
    ++		 * It's not at the end, so it's required to be at the beginning at the
    ++		 * path. Find the starting slash.
    ++		 */
    ++		const char *path_start;
    ++
    ++		path_start = strchr(authority_start, '/');
    ++		Assert(path_start);		/* otherwise we wouldn't have found WK_PREFIX */
    ++
    ++		if (wk_start != path_start)
    ++		{
    ++			libpq_append_conn_error(conn,
    ++									"OAuth discovery URI \"%s\" uses an invalid format",
    ++									wkuri);
    ++			return NULL;
    ++		}
    ++	}
    ++
    ++	/* Checks passed! Now build the issuer. */
    ++	issuer = strdup(wkuri);
    ++	if (!issuer)
    ++	{
    ++		libpq_append_conn_error(conn, "out of memory");
    ++		return NULL;
    ++	}
    ++
    ++	/*
    ++	 * The .well-known components are from [wk_start, wk_end). Remove those to
    ++	 * form the issuer ID, by shifting the path suffix (which may be empty)
    ++	 * leftwards.
    ++	 */
    ++	start_offset = wk_start - wkuri;
    ++	end_offset = wk_end - wkuri;
    ++	end_len = strlen(wk_end) + 1;	/* move the NULL terminator too */
    ++
    ++	memmove(issuer + start_offset, issuer + end_offset, end_len);
    ++
    ++	return issuer;
    ++}
    ++
     +static bool
     +handle_oauth_sasl_error(PGconn *conn, char *msg, int msglen)
     +{
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +	/* Sanity check. */
     +	if (strlen(msg) != msglen)
     +	{
    -+		appendPQExpBufferStr(&conn->errorMessage,
    -+							 libpq_gettext("server's error message contained an embedded NULL, and was discarded"));
    ++		libpq_append_conn_error(conn,
    ++								"server's error message contained an embedded NULL, and was discarded");
     +		return false;
     +	}
     +
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +		errmsg = json_errdetail(err, &lex);
     +
     +	if (errmsg)
    -+		appendPQExpBuffer(&conn->errorMessage,
    -+						  libpq_gettext("failed to parse server's error response: %s"),
    -+						  errmsg);
    ++		libpq_append_conn_error(conn,
    ++								"failed to parse server's error response: %s",
    ++								errmsg);
     +
     +	/* Don't need the error buffer or the JSON lexer anymore. */
     +	termPQExpBuffer(&ctx.errbuf);
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +		return false;
     +
     +	/* TODO: what if these override what the user already specified? */
    ++	/* TODO: what if there's no discovery URI? */
     +	if (ctx.discovery_uri)
     +	{
    ++		char	   *discovery_issuer;
    ++
    ++		/* The URI must correspond to our existing issuer, to avoid mix-ups. */
    ++		discovery_issuer = issuer_from_well_known_uri(conn, ctx.discovery_uri);
    ++		if (!discovery_issuer)
    ++			return false;		/* error message already set */
    ++
    ++		if (strcmp(conn->oauth_issuer_id, discovery_issuer) != 0)
    ++		{
    ++			libpq_append_conn_error(conn,
    ++									"server's discovery document at %s (issuer \"%s\") is incompatible with oauth_issuer (%s)",
    ++									ctx.discovery_uri, discovery_issuer,
    ++									conn->oauth_issuer_id);
    ++
    ++			free(discovery_issuer);
    ++			return false;
    ++		}
    ++
    ++		free(discovery_issuer);
    ++
     +		if (conn->oauth_discovery_uri)
     +			free(conn->oauth_discovery_uri);
     +
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +
     +	if (!ctx.status)
     +	{
    -+		appendPQExpBuffer(&conn->errorMessage,
    -+						  libpq_gettext("server sent error response without a status"));
    ++		libpq_append_conn_error(conn,
    ++								"server sent error response without a status");
     +		return false;
     +	}
     +
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +
     +	if (!request->async)
     +	{
    -+		libpq_append_conn_error(conn, "user-defined OAuth flow provided neither a token nor an async callback");
    ++		libpq_append_conn_error(conn,
    ++								"user-defined OAuth flow provided neither a token nor an async callback");
     +		return PGRES_POLLING_FAILED;
     +	}
     +
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +		 */
     +		if (!request->token)
     +		{
    -+			libpq_append_conn_error(conn, "user-defined OAuth flow did not provide a token");
    ++			libpq_append_conn_error(conn,
    ++									"user-defined OAuth flow did not provide a token");
     +			return PGRES_POLLING_FAILED;
     +		}
     +
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +	return false;
     +}
     +
    ++/*
    ++ * Fill in our issuer identifier and discovery URI, if possible, using the
    ++ * connection parameters. If conn->oauth_discovery_uri can't be populated in
    ++ * this function, it will be requested from the server.
    ++ */
     +static bool
    -+derive_discovery_uri(PGconn *conn)
    ++setup_oauth_parameters(PGconn *conn)
     +{
    -+	PQExpBufferData discovery_buf;
    -+
    -+	if (conn->oauth_discovery_uri || !conn->oauth_issuer)
    ++	/*---
    ++	 * To talk to a server, we require the user to provide issuer and client
    ++	 * identifiers.
    ++	 *
    ++	 * While it's possible for an OAuth client to support multiple issuers, it
    ++	 * requires additional effort to make sure the flows in use are safe -- to
    ++	 * quote RFC 9207,
    ++	 *
    ++	 *     OAuth clients that interact with only one authorization server are
    ++	 *     not vulnerable to mix-up attacks. However, when such clients decide
    ++	 *     to add support for a second authorization server in the future, they
    ++	 *     become vulnerable and need to apply countermeasures to mix-up
    ++	 *     attacks.
    ++	 *
    ++	 * For now, we allow only one.
    ++	 */
    ++	if (!conn->oauth_issuer || !conn->oauth_client_id)
     +	{
    -+		/*
    -+		 * Either we already have one, or we aren't able to derive one
    -+		 * ourselves. The latter case is not an error condition; we'll just
    -+		 * ask the server to provide one for us.
    -+		 */
    -+		return true;
    ++		libpq_append_conn_error(conn,
    ++								"server requires OAuth authentication, but oauth_issuer and oauth_client_id are not both set");
    ++		return false;
     +	}
     +
    -+	initPQExpBuffer(&discovery_buf);
    -+
    -+	Assert(!conn->oauth_discovery_uri);
    -+	Assert(conn->oauth_issuer);
    -+
     +	/*
    -+	 * If we don't yet have a discovery URI, but the user gave us an explicit
    -+	 * issuer, use the .well-known discovery URI for that issuer.
    ++	 * oauth_issuer is interpreted differently if it's a well-known discovery
    ++	 * URI rather than just an issuer identifier.
     +	 */
    -+	appendPQExpBufferStr(&discovery_buf, conn->oauth_issuer);
    -+	appendPQExpBufferStr(&discovery_buf, "/.well-known/openid-configuration");
    -+
    -+	if (PQExpBufferDataBroken(discovery_buf))
    -+		goto cleanup;
    ++	if (strstr(conn->oauth_issuer, WK_PREFIX) != NULL)
    ++	{
    ++		/*
    ++		 * Convert the URI back to an issuer identifier. (This also performs
    ++		 * validation of the URI format.)
    ++		 */
    ++		conn->oauth_issuer_id = issuer_from_well_known_uri(conn,
    ++														   conn->oauth_issuer);
    ++		if (!conn->oauth_issuer_id)
    ++			return false;		/* error message already set */
     +
    -+	conn->oauth_discovery_uri = strdup(discovery_buf.data);
    ++		conn->oauth_discovery_uri = strdup(conn->oauth_issuer);
    ++		if (!conn->oauth_discovery_uri)
    ++		{
    ++			libpq_append_conn_error(conn, "out of memory");
    ++			return false;
    ++		}
    ++	}
    ++	else
    ++	{
    ++		/*
    ++		 * Treat oauth_issuer as an issuer identifier. We'll ask the server
    ++		 * for the discovery URI.
    ++		 */
    ++		conn->oauth_issuer_id = strdup(conn->oauth_issuer);
    ++		if (!conn->oauth_issuer_id)
    ++		{
    ++			libpq_append_conn_error(conn, "out of memory");
    ++			return false;
    ++		}
    ++	}
     +
    -+cleanup:
    -+	termPQExpBuffer(&discovery_buf);
    -+	return (conn->oauth_discovery_uri != NULL);
    ++	return true;
     +}
     +
     +static SASLStatus
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +			/* We begin in the initial response phase. */
     +			Assert(inputlen == -1);
     +
    -+			if (!derive_discovery_uri(conn))
    ++			if (!setup_oauth_parameters(conn))
     +				return SASL_FAILED;
     +
     +			if (conn->oauth_discovery_uri)
     +			{
    -+				if (!conn->oauth_client_id)
    -+				{
    -+					/* We can't talk to a server without a client identifier. */
    -+					libpq_append_conn_error(conn, "no oauth_client_id is set for the connection");
    -+					return SASL_FAILED;
    -+				}
    -+
     +				/*
     +				 * Decide whether we're using a user-provided OAuth flow, or
     +				 * the one we have built in.
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +			 * successfully after telling us it was going to fail. Neither is
     +			 * acceptable.
     +			 */
    -+			appendPQExpBufferStr(&conn->errorMessage,
    -+								 libpq_gettext("server sent additional OAuth data after error\n"));
    ++			libpq_append_conn_error(conn,
    ++									"server sent additional OAuth data after error");
     +			return SASL_FAILED;
     +
     +		default:
    -+			appendPQExpBufferStr(&conn->errorMessage,
    -+								 libpq_gettext("invalid OAuth exchange state\n"));
    ++			libpq_append_conn_error(conn, "invalid OAuth exchange state");
     +			break;
     +	}
     +
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +		state->free_async_ctx(state->conn, state->async_ctx);
     +
     +	free(state);
    ++}
    ++
    ++/*
    ++ * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment.
    ++ */
    ++bool
    ++oauth_unsafe_debugging_enabled(void)
    ++{
    ++	const char *env = getenv("PGOAUTHDEBUG");
    ++
    ++	return (env && strcmp(env, "UNSAFE") == 0);
     +}
     
      ## src/interfaces/libpq/fe-auth-oauth.h (new) ##
    @@ src/interfaces/libpq/fe-auth-oauth.h (new)
     +} fe_oauth_state;
     +
     +extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn, pgsocket *altsock);
    ++extern bool oauth_unsafe_debugging_enabled(void);
     +
     +#endif							/* FE_AUTH_OAUTH_H */
     
    @@ src/interfaces/libpq/fe-connect.c: freePGconn(PGconn *conn)
      	free(conn->target_session_attrs);
      	free(conn->load_balance_hosts);
     +	free(conn->oauth_issuer);
    ++	free(conn->oauth_issuer_id);
     +	free(conn->oauth_discovery_uri);
     +	free(conn->oauth_client_id);
     +	free(conn->oauth_client_secret);
    @@ src/interfaces/libpq/libpq-int.h: struct pg_conn
      								 * connection that's used for queries */
      
     +	/* OAuth v2 */
    -+	char	   *oauth_issuer;	/* token issuer URL */
    ++	char	   *oauth_issuer;	/* token issuer/URL */
    ++	char	   *oauth_issuer_id;	/* token issuer identifier */
     +	char	   *oauth_discovery_uri;	/* URI of the issuer's discovery
     +										 * document */
     +	char	   *oauth_client_id;	/* client identifier */
    @@ src/test/modules/oauth_validator/Makefile (new)
     +#
     +#-------------------------------------------------------------------------
     +
    -+MODULES = validator
    ++MODULES = validator fail_validator
     +PGFILEDESC = "validator - test OAuth validator module"
     +
     +PROGRAM = oauth_hook_client
    @@ src/test/modules/oauth_validator/Makefile (new)
     +
     +endif
     
    + ## src/test/modules/oauth_validator/fail_validator.c (new) ##
    +@@
    ++/*-------------------------------------------------------------------------
    ++ *
    ++ * fail_validator.c
    ++ *	  Test module for serverside OAuth token validation callbacks, which always
    ++ *	  fails
    ++ *
    ++ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
    ++ * Portions Copyright (c) 1994, Regents of the University of California
    ++ *
    ++ * src/test/modules/oauth_validator/fail_validator.c
    ++ *
    ++ *-------------------------------------------------------------------------
    ++ */
    ++
    ++#include "postgres.h"
    ++
    ++#include "fmgr.h"
    ++#include "libpq/oauth.h"
    ++#include "miscadmin.h"
    ++#include "utils/guc.h"
    ++#include "utils/memutils.h"
    ++
    ++PG_MODULE_MAGIC;
    ++
    ++static ValidatorModuleResult *fail_token(ValidatorModuleState *state,
    ++										 const char *token,
    ++										 const char *role);
    ++
    ++static const OAuthValidatorCallbacks validator_callbacks = {
    ++	.validate_cb = fail_token,
    ++};
    ++
    ++const OAuthValidatorCallbacks *
    ++_PG_oauth_validator_module_init(void)
    ++{
    ++	return &validator_callbacks;
    ++}
    ++
    ++static ValidatorModuleResult *
    ++fail_token(ValidatorModuleState *state, const char *token, const char *role)
    ++{
    ++	elog(FATAL, "fail_validator: sentinel error");
    ++	pg_unreachable();
    ++}
    +
      ## src/test/modules/oauth_validator/meson.build (new) ##
     @@
     +# Copyright (c) 2024, PostgreSQL Global Development Group
    @@ src/test/modules/oauth_validator/meson.build (new)
     +)
     +test_install_libs += validator
     +
    ++fail_validator_sources = files(
    ++  'fail_validator.c',
    ++)
    ++
    ++if host_system == 'windows'
    ++  fail_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
    ++    '--NAME', 'fail_validator',
    ++    '--FILEDESC', 'fail_validator - failing OAuth validator module',])
    ++endif
    ++
    ++fail_validator = shared_module('fail_validator',
    ++  fail_validator_sources,
    ++  kwargs: pg_test_mod_args,
    ++)
    ++test_install_libs += fail_validator
    ++
     +oauth_hook_client_sources = files(
     +  'oauth_hook_client.c',
     +)
    @@ src/test/modules/oauth_validator/t/001_server.pl (new)
     +use strict;
     +use warnings FATAL => 'all';
     +
    -+use JSON::PP qw(encode_json);
    ++use JSON::PP     qw(encode_json);
     +use MIME::Base64 qw(encode_base64);
     +use PostgreSQL::Test::Cluster;
     +use PostgreSQL::Test::Utils;
    @@ src/test/modules/oauth_validator/t/001_server.pl (new)
     +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->append_conf('postgresql.conf',
    ++	"oauth_validator_libraries = 'validator'\n");
     +$node->start;
     +
     +$node->safe_psql('postgres', 'CREATE USER test;');
    @@ src/test/modules/oauth_validator/t/001_server.pl (new)
     +{
     +	my $exit_code = $?;
     +
    -+	$webserver->stop() if defined $webserver; # might have been SKIP'd
    ++	$webserver->stop() if defined $webserver;    # might have been SKIP'd
     +
     +	$? = $exit_code;
     +}
     +
     +my $port = $webserver->port();
    -+my $issuer = "127.0.0.1:$port";
    ++my $issuer = "http://localhost:$port";
     +
     +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"
    -+local all testparam oauth issuer="$issuer/param"     scope="openid postgres"
    ++$node->append_conf(
    ++	'pg_hba.conf', qq{
    ++local all test      oauth issuer="$issuer"       scope="openid postgres"
    ++local all testalt   oauth issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
    ++local all testparam oauth issuer="$issuer/param" scope="openid postgres"
     +});
     +$node->reload;
     +
    @@ src/test/modules/oauth_validator/t/001_server.pl (new)
     +
     +# To test against HTTP rather than HTTPS, we need to enable PGOAUTHDEBUG. But
     +# first, check to make sure the client refuses such connections by default.
    -+$node->connect_fails("user=test dbname=postgres oauth_client_id=f02c6361-0635",
    -+					 "HTTPS is required without debug mode",
    -+					 expected_stderr => qr/failed to fetch OpenID discovery document: Unsupported protocol/);
    ++$node->connect_fails(
    ++	"user=test dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
    ++	"HTTPS is required without debug mode",
    ++	expected_stderr =>
    ++	  qr@OAuth discovery URI "\Q$issuer\E/.well-known/openid-configuration" must use HTTPS@
    ++);
     +
     +$ENV{PGOAUTHDEBUG} = "UNSAFE";
     +
     +my $user = "test";
    -+if ($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@))
    ++if ($node->connect_ok(
    ++		"user=$user dbname=postgres oauth_issuer=$issuer 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/,
    -+					 ]);
    ++	$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.
    ++# The /alternate issuer uses slightly different parameters, along with an
    ++# OAuth-style discovery document.
     +$user = "testalt";
    -+if ($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@))
    ++if ($node->connect_ok(
    ++		"user=$user dbname=postgres oauth_issuer=$issuer/alternate 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/,
    -+					 ]);
    ++	$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;
     +}
     +
    ++# The issuer linked by the server must match the client's oauth_issuer setting.
    ++$node->connect_fails(
    ++	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0636",
    ++	"oauth_issuer must match discovery",
    ++	expected_stderr =>
    ++	  qr@server's discovery document at \Q$issuer/.well-known/oauth-authorization-server/alternate\E \(issuer "\Q$issuer/alternate\E"\) is incompatible with oauth_issuer \(\Q$issuer\E\)@
    ++);
    ++
     +# Make sure the client_id and secret are correctly encoded. $vschars contains
     +# every allowed character for a client_id/_secret (the "VSCHAR" class).
     +# $vschars_esc is additionally backslash-escaped for inclusion in a
    @@ src/test/modules/oauth_validator/t/001_server.pl (new)
     +  " !\"#\$%&\\'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
     +
     +$node->connect_ok(
    -+	"user=$user dbname=postgres oauth_client_id='$vschars_esc'",
    ++	"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id='$vschars_esc'",
     +	"escapable characters: client_id",
     +	expected_stderr =>
     +	  qr@Visit https://example\.org/ and enter the code: postgresuser@);
     +$node->connect_ok(
    -+	"user=$user dbname=postgres oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'",
    ++	"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'",
     +	"escapable characters: client_id and secret",
     +	expected_stderr =>
     +	  qr@Visit https://example\.org/ and enter the code: postgresuser@);
    @@ src/test/modules/oauth_validator/t/001_server.pl (new)
     +# oauth_client_id.
     +#
     +
    -+my $common_connstr = "user=testparam dbname=postgres ";
    ++my $common_connstr =
    ++  "user=testparam dbname=postgres oauth_issuer=$issuer/param ";
     +my $base_connstr = $common_connstr;
     +
     +sub connstr
    @@ src/test/modules/oauth_validator/t/001_server.pl (new)
     +$node->connect_ok(
     +	connstr(),
     +	"connect to /param",
    -+	expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@
    -+);
    ++	expected_stderr =>
    ++	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
     +
     +$node->connect_ok(
     +	connstr(stage => 'token', retries => 1),
     +	"token retry",
    -+	expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@
    -+);
    ++	expected_stderr =>
    ++	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
     +$node->connect_ok(
     +	connstr(stage => 'token', retries => 2),
     +	"token retry (twice)",
    -+	expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@
    -+);
    ++	expected_stderr =>
    ++	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
     +$node->connect_ok(
     +	connstr(stage => 'all', retries => 1, interval => 2),
     +	"token retry (two second interval)",
    -+	expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@
    -+);
    ++	expected_stderr =>
    ++	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
     +$node->connect_ok(
     +	connstr(stage => 'all', retries => 1, interval => JSON::PP::null),
     +	"token retry (default interval)",
    -+	expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@
    -+);
    ++	expected_stderr =>
    ++	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
     +
     +$node->connect_ok(
     +	connstr(stage => 'all', content_type => 'application/json;charset=utf-8'),
     +	"content type with charset",
    -+	expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@
    -+);
    ++	expected_stderr =>
    ++	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
     +$node->connect_ok(
    -+	connstr(stage => 'all', content_type => "application/json \t;\t charset=utf-8"),
    ++	connstr(
    ++		stage => 'all',
    ++		content_type => "application/json \t;\t charset=utf-8"),
     +	"content type with charset (whitespace)",
    -+	expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@
    -+);
    ++	expected_stderr =>
    ++	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
     +$node->connect_ok(
     +	connstr(stage => 'device', uri_spelling => "verification_url"),
     +	"alternative spelling of verification_uri",
    -+	expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@
    -+);
    ++	expected_stderr =>
    ++	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
     +
     +$node->connect_fails(
     +	connstr(stage => 'device', huge_response => JSON::PP::true),
     +	"bad device authz response: overlarge JSON",
    -+	expected_stderr => qr/failed to obtain device authorization: response is too large/
    -+);
    ++	expected_stderr =>
    ++	  qr/failed to obtain device authorization: response is too large/);
     +$node->connect_fails(
     +	connstr(stage => 'token', huge_response => JSON::PP::true),
     +	"bad token response: overlarge JSON",
    -+	expected_stderr => qr/failed to obtain access token: response is too large/
    -+);
    ++	expected_stderr =>
    ++	  qr/failed to obtain access token: response is too large/);
     +
     +$node->connect_fails(
     +	connstr(stage => 'device', content_type => 'text/plain'),
     +	"bad device authz response: wrong content type",
    -+	expected_stderr => qr/failed to parse device authorization: unexpected content type/
    -+);
    ++	expected_stderr =>
    ++	  qr/failed to parse device authorization: unexpected content type/);
     +$node->connect_fails(
     +	connstr(stage => 'token', content_type => 'text/plain'),
     +	"bad token response: wrong content type",
    -+	expected_stderr => qr/failed to parse access token response: unexpected content type/
    -+);
    ++	expected_stderr =>
    ++	  qr/failed to parse access token response: unexpected content type/);
     +$node->connect_fails(
     +	connstr(stage => 'token', content_type => 'application/jsonx'),
     +	"bad token response: wrong content type (correct prefix)",
    -+	expected_stderr => qr/failed to parse access token response: unexpected content type/
    -+);
    ++	expected_stderr =>
    ++	  qr/failed to parse access token response: unexpected content type/);
     +
     +$node->connect_fails(
    -+	connstr(stage => 'all', interval => ~0, retries => 1, retry_code => "slow_down"),
    ++	connstr(
    ++		stage => 'all',
    ++		interval => ~0,
    ++		retries => 1,
    ++		retry_code => "slow_down"),
     +	"bad token response: server overflows the device authz interval",
    -+	expected_stderr => qr/failed to obtain access token: slow_down interval overflow/
    -+);
    ++	expected_stderr =>
    ++	  qr/failed to obtain access token: slow_down interval overflow/);
     +
     +$node->connect_fails(
     +	connstr(stage => 'token', error_code => "invalid_grant"),
    @@ src/test/modules/oauth_validator/t/001_server.pl (new)
     +#
     +
     +# Searching the logs is easier if OAuth parameter discovery isn't cluttering
    -+# things up; hardcode the issuer. (Scope is hardcoded to empty to cover that
    -+# case as well.)
    ++# things up; hardcode the discovery URI. (Scope is hardcoded to empty to cover
    ++# that case as well.)
     +$common_connstr =
    -+  "user=test dbname=postgres oauth_issuer=$issuer oauth_scope=''";
    ++  "user=test dbname=postgres oauth_issuer=$issuer/.well-known/openid-configuration oauth_scope=''";
     +
     +$bgconn->query_safe("ALTER SYSTEM SET oauth_validator.authn_id TO ''");
     +$node->reload;
    @@ src/test/modules/oauth_validator/t/001_server.pl (new)
     +$log_start =
     +  $node->wait_for_log(qr/reloading configuration files/, $log_start);
     +
    ++#
    ++# Test multiple validators.
    ++#
    ++
    ++$node->append_conf('postgresql.conf',
    ++	"oauth_validator_libraries = 'validator, fail_validator'\n");
    ++
    ++# With multiple validators, every HBA line must explicitly declare one.
    ++my $result = $node->restart(fail_ok => 1);
    ++is($result, 0,
    ++	'restart fails without explicit validators in oauth HBA entries');
    ++
    ++$log_start = $node->wait_for_log(
    ++	qr/authentication method "oauth" requires argument "validator" to be set/,
    ++	$log_start);
    ++
    ++unlink($node->data_dir . '/pg_hba.conf');
    ++$node->append_conf(
    ++	'pg_hba.conf', qq{
    ++local all test    oauth validator=validator      issuer="$issuer"           scope="openid postgres"
    ++local all testalt oauth validator=fail_validator issuer="$issuer/alternate" scope="openid postgres alt"
    ++});
    ++$node->restart;
    ++
    ++$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
    ++
    ++# The test user should work as before.
    ++$user = "test";
    ++if ($node->connect_ok(
    ++		"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
    ++		"validator is used for $user",
    ++		expected_stderr =>
    ++		  qr@Visit https://example\.com/ and enter the code: postgresuser@))
    ++{
    ++	$log_start = $node->wait_for_log(qr/connection authorized/, $log_start);
    ++}
    ++
    ++# testalt should be routed through the fail_validator.
    ++$user = "testalt";
    ++$node->connect_fails(
    ++	"user=$user dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
    ++	"fail_validator is used for $user",
    ++	expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/);
    ++
     +$node->stop;
     +
     +done_testing();
    @@ src/test/modules/oauth_validator/t/002_client.pl (new)
     +use MIME::Base64 qw(encode_base64);
     +use PostgreSQL::Test::Cluster;
     +use PostgreSQL::Test::Utils;
    -+use PostgreSQL::Test::OAuthServer;
     +use Test::More;
     +
     +if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
    @@ src/test/modules/oauth_validator/t/002_client.pl (new)
     +$node->init;
     +$node->append_conf('postgresql.conf', "log_connections = on\n");
     +$node->append_conf('postgresql.conf',
    -+	"oauth_validator_library = 'validator'\n");
    ++	"oauth_validator_libraries = 'validator'\n");
     +$node->start;
     +
     +$node->safe_psql('postgres', 'CREATE USER test;');
     +
    -+my $issuer = "https://127.0.0.1:54321";
    ++# These tests don't use the builtin flow, and we don't have an authorization
    ++# server running, so the address used here shouldn't matter. Use an invalid IP
    ++# address, so if there's some cascade of errors that causes the client to
    ++# attempt a connection, we'll fail noisily.
    ++my $issuer = "https://256.256.256.256";
     +my $scope = "openid postgres";
     +
     +unlink($node->data_dir . '/pg_hba.conf');
    @@ src/test/modules/oauth_validator/t/002_client.pl (new)
     +
     +my $user = "test";
     +my $base_connstr = $node->connstr() . " user=$user";
    -+my $common_connstr = "$base_connstr oauth_client_id=myID";
    ++my $common_connstr =
    ++  "$base_connstr oauth_issuer=$issuer oauth_client_id=myID";
     +
     +sub test
     +{
    @@ src/test/modules/oauth_validator/t/oauth_server.py (new)
     +        """
     +        Switches the behavior of the provider depending on the issuer URI.
     +        """
    -+        self._alt_issuer = self.path.startswith("/alternate/")
    ++        self._alt_issuer = (
    ++            self.path.startswith("/alternate/")
    ++            or self.path == "/.well-known/oauth-authorization-server/alternate"
    ++        )
     +        self._parameterized = self.path.startswith("/param/")
     +
     +        if self._alt_issuer:
    -+            self.path = self.path.removeprefix("/alternate")
    ++            # The /alternate issuer uses IETF-style .well-known URIs.
    ++            if self.path.startswith("/.well-known/"):
    ++                self.path = self.path.removesuffix("/alternate")
    ++            else:
    ++                self.path = self.path.removeprefix("/alternate")
     +        elif self._parameterized:
     +            self.path = self.path.removeprefix("/param")
     +
    @@ src/test/modules/oauth_validator/t/oauth_server.py (new)
     +        self._response_code = 200
     +        self._check_issuer()
     +
    -+        if self.path == "/.well-known/openid-configuration":
    ++        config_path = "/.well-known/openid-configuration"
    ++        if self._alt_issuer:
    ++            config_path = "/.well-known/oauth-authorization-server"
    ++
    ++        if self.path == config_path:
     +            resp = self.config()
     +        else:
     +            self.send_error(404, "Not Found")
2:  01df79980b ! 2:  3d169848db DO NOT MERGE: Add pytest suite for OAuth
    @@ .cirrus.tasks.yml: task:
        matrix:
          - name: Linux - Debian Bookworm - Autoconf
     @@ .cirrus.tasks.yml: task:
    - 
    -       # Also build & test in a 32bit build - it's gotten rare to test that
    -       # locally.
    +       # can easily provide some here by running one of the sets of tests that
    +       # way. Newer versions of python insist on changing the LC_CTYPE away
    +       # from C, prevent that with PYTHONCOERCECLOCALE.
     +      # XXX 32-bit Python tests are currently disabled, as the system's 64-bit
     +      # Python modules can't link against libpq.
    -       configure_32_script: |
    +       test_world_32_script: |
              su postgres <<-EOF
    -           export CC='ccache gcc -m32'
     +          export PG_TEST_EXTRA="${PG_TEST_EXTRA//python}"
    -           meson setup \
    -             --buildtype=debug \
    -             -Dcassert=true -Dinjection_points=true \
    +           ulimit -c unlimited
    +           PYTHONCOERCECLOCALE=0 LANG=C meson test $MTEST_ARGS -C build-32 --num-processes ${TEST_JOBS}
    +         EOF
     
      ## meson.build ##
     @@ meson.build: else
    @@ meson.build: foreach test_dir : tests
     +        env.set(name, value)
     +      endforeach
     +
    -+      if get_option('PG_TEST_EXTRA').contains('python')
    -+        reqs = files(t['requirements'])
    -+        test('install_' + venv_name,
    -+          python,
    -+          args: [ make_venv, '--requirements', reqs, venv_path ],
    -+          env: env,
    -+          priority: setup_tests_priority - 1,  # must run after tmp_install
    -+          is_parallel: false,
    -+          suite: ['setup'],
    -+          timeout: 60,  # 30s is too short for the cryptography package compile
    -+        )
    -+      endif
    ++      reqs = files(t['requirements'])
    ++      test('install_' + venv_name,
    ++        python,
    ++        args: [ make_venv, '--requirements', reqs, venv_path ],
    ++        env: env,
    ++        priority: setup_tests_priority - 1,  # must run after tmp_install
    ++        is_parallel: false,
    ++        suite: ['setup'],
    ++        timeout: 60,  # 30s is too short for the cryptography package compile
    ++      )
     +
     +      test_group = test_dir['name']
     +      test_output = test_result_dir / test_group / kind
    @@ meson.build: foreach test_dir : tests
     +        'timeout': 1000,
     +        'depends': test_deps,
     +        'env': env,
    -+      }
    ++      } + t.get('test_kwargs', {})
     +
     +      if fs.is_dir(venv_path / 'Scripts')
     +        # Windows virtualenv layout
    @@ meson.build: foreach test_dir : tests
     +        testwrap_pytest = testwrap_base + [
     +          '--testgroup', test_group,
     +          '--testname', pyt_p,
    ++          '--skip-without-extra', 'python',
     +        ]
    -+        if not get_option('PG_TEST_EXTRA').contains('python')
    -+          testwrap_pytest += ['--skip', '"python" tests not enabled in PG_TEST_EXTRA']
    -+        endif
     +
     +        test(test_group / pyt_p,
     +          python,
    @@ src/test/python/client/conftest.py (new)
     +#
     +
     +import contextlib
    ++import datetime
    ++import functools
    ++import ipaddress
    ++import os
     +import socket
     +import sys
     +import threading
    @@ src/test/python/client/conftest.py (new)
     +import psycopg2
     +import psycopg2.extras
     +import pytest
    ++from cryptography import x509
    ++from cryptography.hazmat.primitives import hashes, serialization
    ++from cryptography.hazmat.primitives.asymmetric import rsa
    ++from cryptography.x509.oid import NameOID
     +
     +import pq3
     +
    @@ src/test/python/client/conftest.py (new)
     +    with sock:
     +        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
     +            yield conn
    ++
    ++
    ++@pytest.fixture(scope="session")
    ++def certpair(tmp_path_factory):
    ++    """
    ++    Yields a (cert, key) pair of file paths that can be used by a TLS server.
    ++    The certificate is issued for "localhost" and its standard IPv4/6 addresses.
    ++    """
    ++
    ++    tmpdir = tmp_path_factory.mktemp("certs")
    ++    now = datetime.datetime.now(datetime.timezone.utc)
    ++
    ++    # https://cryptography.io/en/latest/x509/tutorial/#creating-a-self-signed-certificate
    ++    key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
    ++
    ++    subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
    ++    altNames = [
    ++        x509.DNSName("localhost"),
    ++        x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
    ++        x509.IPAddress(ipaddress.IPv6Address("::1")),
    ++    ]
    ++    cert = (
    ++        x509.CertificateBuilder()
    ++        .subject_name(subject)
    ++        .issuer_name(issuer)
    ++        .public_key(key.public_key())
    ++        .serial_number(x509.random_serial_number())
    ++        .not_valid_before(now)
    ++        .not_valid_after(now + datetime.timedelta(minutes=10))
    ++        .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
    ++        .add_extension(x509.SubjectAlternativeName(altNames), critical=False)
    ++    ).sign(key, hashes.SHA256())
    ++
    ++    # Writing the key with mode 0600 lets us use this from the server side, too.
    ++    keypath = str(tmpdir / "key.pem")
    ++    with open(keypath, "wb", opener=functools.partial(os.open, mode=0o600)) as f:
    ++        f.write(
    ++            key.private_bytes(
    ++                encoding=serialization.Encoding.PEM,
    ++                format=serialization.PrivateFormat.PKCS8,
    ++                encryption_algorithm=serialization.NoEncryption(),
    ++            )
    ++        )
    ++
    ++    certpath = str(tmpdir / "cert.pem")
    ++    with open(certpath, "wb") as f:
    ++        f.write(cert.public_bytes(serialization.Encoding.PEM))
    ++
    ++    return certpath, keypath
     
      ## src/test/python/client/test_client.py (new) ##
     @@
    @@ src/test/python/client/test_oauth.py (new)
     +
     +import base64
     +import collections
    ++import contextlib
     +import ctypes
     +import http.server
     +import json
    @@ src/test/python/client/test_oauth.py (new)
     +import os
     +import platform
     +import secrets
    ++import socket
    ++import ssl
     +import sys
     +import threading
     +import time
    @@ src/test/python/client/test_oauth.py (new)
     +    )
     +
     +
    -+def xtest_oauth_success(conn):  # TODO
    -+    initial = start_oauth_handshake(conn)
    -+
    -+    auth = get_auth_value(initial)
    -+    assert auth.startswith(b"Bearer ")
    -+
    -+    # Accept the token. TODO actually validate
    -+    pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal)
    -+    finish_handshake(conn)
    -+
    -+
     +class RawResponse(str):
     +    """
     +    Returned by registered endpoint callbacks to take full control of the
    @@ src/test/python/client/test_oauth.py (new)
     +
     +class OpenIDProvider(threading.Thread):
     +    """
    -+    A thread that runs a mock OpenID provider server.
    ++    A thread that runs a mock OpenID provider server on an SSL-enabled socket.
     +    """
     +
    -+    def __init__(self, *, port):
    ++    def __init__(self, ssl_socket):
     +        super().__init__()
     +
     +        self.exception = None
     +
    -+        addr = ("", port)
    -+        self.server = self._Server(addr, self._Handler)
    ++        _, port = ssl_socket.getsockname()
     +
    -+        # TODO: allow HTTPS only, somehow
     +        oauth = self._OAuthState()
     +        oauth.host = f"localhost:{port}"
    -+        oauth.issuer = f"http://localhost:{port}"
    ++        oauth.issuer = f"https://localhost:{port}"
     +
     +        # The following endpoints are required to be advertised by providers,
     +        # even though our chosen client implementation does not actually make
    @@ src/test/python/client/test_oauth.py (new)
     +        )
     +        oauth.register_endpoint("jwks_uri", "GET", "/keys", self._jwks_handler)
     +
    ++        self.server = self._HTTPSServer(ssl_socket, self._Handler)
     +        self.server.oauth = oauth
     +
     +    def run(self):
    @@ src/test/python/client/test_oauth.py (new)
     +
     +            return 200, doc
     +
    -+    class _Server(http.server.HTTPServer):
    ++    class _HTTPSServer(http.server.HTTPServer):
    ++        def __init__(self, ssl_socket, handler_cls):
    ++            # Attach the SSL socket to the server. We don't bind/activate since
    ++            # the socket is already listening.
    ++            super().__init__(None, handler_cls, bind_and_activate=False)
    ++            self.socket = ssl_socket
    ++            self.server_address = self.socket.getsockname()
    ++
    ++        def shutdown_request(self, request):
    ++            # Cleanly unwrap the SSL socket before shutting down the connection;
    ++            # otherwise careful clients will complain about truncation.
    ++            try:
    ++                request = request.unwrap()
    ++            except (ssl.SSLEOFError, ConnectionResetError, BrokenPipeError):
    ++                # The client already closed (or aborted) the connection without
    ++                # a clean shutdown. This is seen on some platforms during tests
    ++                # that break the HTTP protocol. Just return and have the server
    ++                # close the socket.
    ++                return
    ++
    ++            super().shutdown_request(request)
    ++
     +        def handle_error(self, request, addr):
     +            self.shutdown_request(request)
     +            raise
    @@ src/test/python/client/test_oauth.py (new)
     +            oauth = self.server.oauth
     +            assert self.headers["Host"] == oauth.host
     +
    ++            # XXX: BaseHTTPRequestHandler collapses leading slashes in the path
    ++            # to work around an open redirection vuln (gh-87389) in
    ++            # SimpleHTTPServer. But we're not using SimpleHTTPServer, and we
    ++            # want to test repeating leading slashes, so that's not very
    ++            # helpful. Put them back.
    ++            orig_path = self.raw_requestline.split()[1]
    ++            orig_path = str(orig_path, "iso-8859-1")
    ++            assert orig_path.endswith(self.path)  # sanity check
    ++            self.path = orig_path
    ++
     +            if handler is None:
     +                handler = oauth.endpoint(self.command, self.path)
     +                assert (
    @@ src/test/python/client/test_oauth.py (new)
     +    monkeypatch.setenv("PGOAUTHDEBUG", "UNSAFE")
     +
     +
    ++@pytest.fixture(autouse=True)
    ++def trust_certpair_in_client(monkeypatch, certpair):
    ++    """
    ++    Set a trusted CA file for OAuth client connections.
    ++    """
    ++    monkeypatch.setenv("PGOAUTHCAFILE", certpair[0])
    ++
    ++
    ++@pytest.fixture(scope="session")
    ++def ssl_socket(certpair):
    ++    """
    ++    A listening server-side socket for SSL connections, using the certpair
    ++    fixture.
    ++    """
    ++    sock = socket.create_server(("", 0))
    ++
    ++    # The TLS connections we're making are incredibly sensitive to delayed ACKs
    ++    # from the client. (Without TCP_NODELAY, test performance degrades 4-5x.)
    ++    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
    ++
    ++    with contextlib.closing(sock):
    ++        # Wrap the server socket for TLS.
    ++        ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
    ++        ctx.load_cert_chain(*certpair)
    ++
    ++        yield ctx.wrap_socket(sock, server_side=True)
    ++
    ++
     +@pytest.fixture
    -+def openid_provider(unused_tcp_port_factory):
    ++def openid_provider(ssl_socket):
     +    """
     +    A fixture that returns the OAuth state of a running OpenID provider server. The
     +    server will be stopped when the fixture is torn down.
     +    """
    -+    thread = OpenIDProvider(port=unused_tcp_port_factory())
    ++    thread = OpenIDProvider(ssl_socket)
     +    thread.start()
     +
     +    try:
    @@ src/test/python/client/test_oauth.py (new)
     +        pytest.param(True, id="asynchronous"),
     +    ],
     +)
    -+def test_oauth_with_explicit_issuer(
    ++def test_oauth_with_explicit_discovery_uri(
     +    accept,
     +    openid_provider,
     +    asynchronous,
    @@ src/test/python/client/test_oauth.py (new)
     +    openid_provider.content_type = content_type
     +
     +    sock, client = accept(
    -+        oauth_issuer=openid_provider.issuer,
    ++        oauth_issuer=openid_provider.discovery_uri,
     +        oauth_client_id=client_id,
     +        oauth_client_secret=secret,
     +        oauth_scope=scope,
    @@ src/test/python/client/test_oauth.py (new)
     +            client.check_completed()
     +
     +
    ++@pytest.mark.parametrize(
    ++    "server_discovery",
    ++    [
    ++        pytest.param(True, id="server discovery"),
    ++        pytest.param(False, id="direct discovery"),
    ++    ],
    ++)
    ++@pytest.mark.parametrize(
    ++    "issuer, path",
    ++    [
    ++        pytest.param(
    ++            "{issuer}",
    ++            "/.well-known/oauth-authorization-server",
    ++            id="oauth",
    ++        ),
    ++        pytest.param(
    ++            "{issuer}/alt",
    ++            "/.well-known/oauth-authorization-server/alt",
    ++            id="oauth with path, IETF style",
    ++        ),
    ++        pytest.param(
    ++            "{issuer}/alt",
    ++            "/alt/.well-known/oauth-authorization-server",
    ++            id="oauth with path, broken OIDC style",
    ++        ),
    ++        pytest.param(
    ++            "{issuer}/alt",
    ++            "/alt/.well-known/openid-configuration",
    ++            id="openid with path, OIDC style",
    ++        ),
    ++        pytest.param(
    ++            "{issuer}/alt",
    ++            "/.well-known/openid-configuration/alt",
    ++            id="openid with path, IETF style",
    ++        ),
    ++        pytest.param(
    ++            "{issuer}/",
    ++            "//.well-known/openid-configuration",
    ++            id="empty path segment, OIDC style",
    ++        ),
    ++        pytest.param(
    ++            "{issuer}/",
    ++            "/.well-known/openid-configuration/",
    ++            id="empty path segment, IETF style",
    ++        ),
    ++    ],
    ++)
    ++def test_alternate_well_known_paths(
    ++    accept, openid_provider, issuer, path, server_discovery
    ++):
    ++    issuer = issuer.format(issuer=openid_provider.issuer)
    ++    discovery_uri = openid_provider.issuer + path
    ++
    ++    client_id = secrets.token_hex()
    ++    access_token = secrets.token_urlsafe()
    ++
    ++    def discovery_handler(*args):
    ++        """
    ++        Pass-through implementation of the discovery handler. Modifies the
    ++        default document to contain this test's issuer identifier.
    ++        """
    ++        code, doc = openid_provider._default_discovery_handler(*args)
    ++        doc["issuer"] = issuer
    ++        return code, doc
    ++
    ++    openid_provider.register_endpoint(None, "GET", path, discovery_handler)
    ++
    ++    def authorization_endpoint(headers, params):
    ++        resp = {
    ++            "device_code": "12345",
    ++            "user_code": "ABCDE",
    ++            "interval": 0,
    ++            "verification_url": "https://example.com/device",
    ++            "expires_in": 5,
    ++        }
    ++
    ++        return 200, resp
    ++
    ++    openid_provider.register_endpoint(
    ++        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
    ++    )
    ++
    ++    def token_endpoint(headers, params):
    ++        # Successfully finish the request by sending the access bearer token.
    ++        resp = {
    ++            "access_token": access_token,
    ++            "token_type": "bearer",
    ++        }
    ++
    ++        return 200, resp
    ++
    ++    openid_provider.register_endpoint(
    ++        "token_endpoint", "POST", "/token", token_endpoint
    ++    )
    ++
    ++    kwargs = dict(oauth_client_id=client_id)
    ++    if server_discovery:
    ++        kwargs.update(oauth_issuer=issuer)
    ++    else:
    ++        kwargs.update(oauth_issuer=discovery_uri)
    ++
    ++    sock, client = accept(**kwargs)
    ++
    ++    if server_discovery:
    ++        with sock:
    ++            with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
    ++                initial = start_oauth_handshake(conn)
    ++
    ++                # For discovery, the client should send an empty auth header.
    ++                # See RFC 7628, Sec. 4.3.
    ++                auth = get_auth_value(initial)
    ++                assert auth == b""
    ++
    ++                # Always fail the discovery exchange.
    ++                fail_oauth_handshake(
    ++                    conn,
    ++                    {
    ++                        "status": "invalid_token",
    ++                        "openid-configuration": discovery_uri,
    ++                    },
    ++                )
    ++
    ++        # Expect the client to connect again.
    ++        sock, client = accept()
    ++
    ++    with sock:
    ++        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
    ++            initial = start_oauth_handshake(conn)
    ++
    ++            # Validate the token.
    ++            auth = get_auth_value(initial)
    ++            assert auth == f"Bearer {access_token}".encode("ascii")
    ++
    ++            pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal)
    ++            finish_handshake(conn)
    ++
    ++
    ++@pytest.mark.parametrize(
    ++    "server_discovery",
    ++    [
    ++        pytest.param(True, id="server discovery"),
    ++        pytest.param(False, id="direct discovery"),
    ++    ],
    ++)
    ++@pytest.mark.parametrize(
    ++    "issuer, path, expected_error",
    ++    [
    ++        pytest.param(
    ++            "{issuer}",
    ++            "/.well-known/oauth-authorization-server/",
    ++            None,
    ++            id="extra empty segment",
    ++        ),
    ++        pytest.param(
    ++            "{issuer}",
    ++            "?/.well-known/oauth-authorization-server",
    ++            r'OAuth discovery URI ".*" must not contain query or fragment components',
    ++            id="query",
    ++        ),
    ++        pytest.param(
    ++            "{issuer}",
    ++            "#/.well-known/oauth-authorization-server",
    ++            r'OAuth discovery URI ".*" must not contain query or fragment components',
    ++            id="fragment",
    ++        ),
    ++        pytest.param(
    ++            "{issuer}/sub/path",
    ++            "/sub/.well-known/oauth-authorization-server/path",
    ++            r'OAuth discovery URI ".*" uses an invalid format',
    ++            id="sandwiched prefix",
    ++        ),
    ++        pytest.param(
    ++            "{issuer}/path",
    ++            "/path/openid-configuration",
    ++            r'OAuth discovery URI ".*" is not a .well-known URI',
    ++            id="not .well-known",
    ++        ),
    ++        pytest.param(
    ++            "{issuer}",
    ++            "https://.well-known/oauth-authorization-server",
    ++            r'OAuth discovery URI ".*" is not a .well-known URI',
    ++            id=".well-known prefix buried in the authority",
    ++        ),
    ++        pytest.param(
    ++            "{issuer}",
    ++            "/.well-known/oauth-protected-resource",
    ++            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
    ++            id="unknown well-known suffix",
    ++        ),
    ++        pytest.param(
    ++            "{issuer}/path",
    ++            "/path/.well-known/openid-configuration-2",
    ++            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
    ++            id="unknown well-known suffix, OIDC style",
    ++        ),
    ++        pytest.param(
    ++            "{issuer}/path",
    ++            "/.well-known/oauth-authorization-server-2/path",
    ++            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
    ++            id="unknown well-known suffix, IETF style",
    ++        ),
    ++        pytest.param(
    ++            "{issuer}",
    ++            "file:///.well-known/oauth-authorization-server",
    ++            r'OAuth discovery URI ".*" must use HTTPS',
    ++            id="unsupported scheme",
    ++        ),
    ++    ],
    ++)
    ++def test_bad_well_known_paths(
    ++    accept, openid_provider, issuer, path, expected_error, server_discovery
    ++):
    ++    if not server_discovery and "/.well-known/" not in path:
    ++        # An oauth_issuer without a /.well-known/ path segment is just a normal
    ++        # issuer identifier, so this isn't an interesting test.
    ++        pytest.skip("not interesting: direct discovery requires .well-known")
    ++
    ++    issuer = issuer.format(issuer=openid_provider.issuer)
    ++    discovery_uri = urllib.parse.urljoin(openid_provider.issuer, path)
    ++
    ++    client_id = secrets.token_hex()
    ++
    ++    def discovery_handler(*args):
    ++        """
    ++        Pass-through implementation of the discovery handler. Modifies the
    ++        default document to contain this test's issuer identifier.
    ++        """
    ++        code, doc = openid_provider._default_discovery_handler(*args)
    ++        doc["issuer"] = issuer
    ++        return code, doc
    ++
    ++    openid_provider.register_endpoint(None, "GET", path, discovery_handler)
    ++
    ++    def fail(*args):
    ++        """
    ++        No other endpoints should be contacted; fail if the client tries.
    ++        """
    ++        assert False, "endpoint unexpectedly called"
    ++
    ++    openid_provider.register_endpoint(
    ++        "device_authorization_endpoint", "POST", "/device", fail
    ++    )
    ++    openid_provider.register_endpoint("token_endpoint", "POST", "/token", fail)
    ++
    ++    kwargs = dict(oauth_client_id=client_id)
    ++    if server_discovery:
    ++        kwargs.update(oauth_issuer=issuer)
    ++    else:
    ++        kwargs.update(oauth_issuer=discovery_uri)
    ++
    ++    sock, client = accept(**kwargs)
    ++
    ++    if server_discovery:
    ++        with sock:
    ++            with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
    ++                initial = start_oauth_handshake(conn)
    ++
    ++                # For discovery, the client should send an empty auth header.
    ++                # See RFC 7628, Sec. 4.3.
    ++                auth = get_auth_value(initial)
    ++                assert auth == b""
    ++
    ++                # Always fail the discovery exchange.
    ++                resp = {
    ++                    "status": "invalid_token",
    ++                    "openid-configuration": discovery_uri,
    ++                }
    ++                pq3.send(
    ++                    conn,
    ++                    pq3.types.AuthnRequest,
    ++                    type=pq3.authn.SASLContinue,
    ++                    body=json.dumps(resp).encode("utf-8"),
    ++                )
    ++
    ++                # FIXME: the client disconnects at this point; it'd be nicer if
    ++                # it completed the exchange.
    ++
    ++            # The client should not reconnect.
    ++
    ++    else:
    ++        expect_disconnected_handshake(sock)
    ++
    ++    if expected_error is None:
    ++        if server_discovery:
    ++            expected_error = rf"server's discovery document at {discovery_uri} \(issuer \".*\"\) is incompatible with oauth_issuer \({issuer}\)"
    ++        else:
    ++            expected_error = rf"the issuer identifier \({issuer}\) does not match oauth_issuer \(.*\)"
    ++
    ++    with pytest.raises(psycopg2.OperationalError, match=expected_error):
    ++        client.check_completed()
    ++
    ++
     +def expect_disconnected_handshake(sock):
     +    """
     +    Helper for any tests that expect the client to disconnect immediately after
    @@ src/test/python/client/test_oauth.py (new)
     +            )
     +
     +            # The client should disconnect at this point.
    -+            assert not conn.read()
    ++            assert not conn.read(1), "client sent unexpected data"
     +
     +
    -+def test_oauth_requires_client_id(accept, openid_provider):
    -+    sock, client = accept(
    ++@pytest.mark.parametrize(
    ++    "missing",
    ++    [
    ++        pytest.param(["oauth_issuer"], id="missing oauth_issuer"),
    ++        pytest.param(["oauth_client_id"], id="missing oauth_client_id"),
    ++        pytest.param(["oauth_client_id", "oauth_issuer"], id="missing both"),
    ++    ],
    ++)
    ++def test_oauth_requires_issuer_and_client_id(accept, openid_provider, missing):
    ++    params = dict(
     +        oauth_issuer=openid_provider.issuer,
    -+        # Do not set a client ID; this should cause a client error after the
    -+        # server asks for OAUTHBEARER and the client tries to contact the
    -+        # issuer.
    ++        oauth_client_id="some-id",
     +    )
     +
    ++    # Remove required parameters. This should cause a client error after the
    ++    # server asks for OAUTHBEARER and the client tries to contact the issuer.
    ++    for k in missing:
    ++        del params[k]
    ++
    ++    sock, client = accept(**params)
     +    expect_disconnected_handshake(sock)
     +
    -+    expected_error = "no oauth_client_id is set"
    ++    expected_error = "oauth_issuer and oauth_client_id are not both set"
     +    with pytest.raises(psycopg2.OperationalError, match=expected_error):
     +        client.check_completed()
     +
    @@ src/test/python/client/test_oauth.py (new)
     +@pytest.mark.parametrize("scope", ["&", r"+=&/", all_nqchars])
     +def test_url_encoding(accept, openid_provider, client_id, secret, device_code, scope):
     +    sock, client = accept(
    -+        oauth_issuer=openid_provider.issuer,
    ++        oauth_issuer=openid_provider.discovery_uri,
     +        oauth_client_id=client_id,
     +        oauth_client_secret=secret,
     +        oauth_scope=scope,
    @@ src/test/python/client/test_oauth.py (new)
     +    accept, openid_provider, omit_interval, retries, error_code
     +):
     +    sock, client = accept(
    -+        oauth_issuer=openid_provider.issuer,
    ++        oauth_issuer=openid_provider.discovery_uri,
     +        oauth_client_id="some-id",
     +    )
     +
    @@ src/test/python/client/test_oauth.py (new)
     +    access_token = secrets.token_urlsafe()
     +
     +    sock, client = accept(
    -+        oauth_issuer=issuer,
    ++        oauth_issuer=discovery_uri,
     +        oauth_client_id="some-id",
     +        oauth_scope=scope,
     +        async_=asynchronous,
    @@ src/test/python/client/test_oauth.py (new)
     +    client_id = secrets.token_hex()
     +
     +    sock, client = accept(
    -+        oauth_issuer=openid_provider.issuer,
    ++        oauth_issuer=openid_provider.discovery_uri,
     +        oauth_client_id=client_id,
     +    )
     +
    @@ src/test/python/client/test_oauth.py (new)
     +        pytest.skip("not interesting: correct type")
     +
     +    sock, client = accept(
    -+        oauth_issuer=openid_provider.issuer,
    ++        oauth_issuer=openid_provider.discovery_uri,
     +        oauth_client_id=secrets.token_hex(),
     +    )
     +
    @@ src/test/python/client/test_oauth.py (new)
     +    client_id = secrets.token_hex()
     +
     +    sock, client = accept(
    -+        oauth_issuer=openid_provider.issuer,
    ++        oauth_issuer=openid_provider.discovery_uri,
     +        oauth_client_id=client_id,
     +    )
     +
    @@ src/test/python/client/test_oauth.py (new)
     +        pytest.skip("not interesting: correct type")
     +
     +    sock, client = accept(
    -+        oauth_issuer=openid_provider.issuer,
    ++        oauth_issuer=openid_provider.discovery_uri,
     +        oauth_client_id=secrets.token_hex(),
     +    )
     +
    @@ src/test/python/client/test_oauth.py (new)
     +    ],
     +)
     +def test_oauth_discovery(accept, openid_provider, base_response, scope, success):
    -+    sock, client = accept(oauth_client_id=secrets.token_hex())
    ++    sock, client = accept(
    ++        oauth_issuer=openid_provider.issuer,
    ++        oauth_client_id=secrets.token_hex(),
    ++    )
     +
     +    device_code = secrets.token_hex()
     +    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
    @@ src/test/python/client/test_oauth.py (new)
     +    ],
     +)
     +def test_oauth_discovery_server_error(accept, response, expected_error):
    -+    sock, client = accept(oauth_client_id=secrets.token_hex())
    ++    sock, client = accept(
    ++        oauth_issuer="https://example.com",
    ++        oauth_client_id=secrets.token_hex(),
    ++    )
     +
     +    with sock:
     +        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
    @@ src/test/python/client/test_oauth.py (new)
     +        client.check_completed()
     +
     +
    ++# All of these tests are expected to fail before libpq tries to actually attempt
    ++# a connection to any endpoint. To avoid hitting the network in the event that a
    ++# test fails, an invalid IPv4 address (256.256.256.256) is used as a hostname.
     +@pytest.mark.parametrize(
     +    "bad_response,expected_error",
     +    [
    @@ src/test/python/client/test_oauth.py (new)
     +                200,
     +                {
     +                    "grant_types_supported": ["something"],
    -+                    "token_endpoint": "https://example.com/",
    ++                    "token_endpoint": "https://256.256.256.256/",
     +                    "issuer": 123,
     +                },
     +            ),
    @@ src/test/python/client/test_oauth.py (new)
     +            id="non-string issuer after other ignored fields",
     +        ),
     +        pytest.param(
    -+            (200, {"token_endpoint": "https://example.com/"}),
    ++            (200, {"token_endpoint": "https://256.256.256.256/"}),
     +            r'failed to parse OpenID discovery document: field "issuer" is missing',
     +            id="missing issuer",
     +        ),
     +        pytest.param(
    -+            (200, {"issuer": "https://example.com/"}),
    ++            (200, {"issuer": "{issuer}"}),
     +            r'failed to parse OpenID discovery document: field "token_endpoint" is missing',
     +            id="missing token endpoint",
     +        ),
    @@ src/test/python/client/test_oauth.py (new)
     +            (
     +                200,
     +                {
    -+                    "issuer": "https://example.com",
    -+                    "token_endpoint": "https://example.com/token",
    -+                    "device_authorization_endpoint": "https://example.com/dev",
    ++                    "issuer": "{issuer}",
    ++                    "token_endpoint": "https://256.256.256.256/token",
    ++                    "device_authorization_endpoint": "https://256.256.256.256/dev",
     +                },
     +            ),
    -+            r'cannot run OAuth device authorization: issuer "https://example.com" does not support device code grants',
    ++            r'cannot run OAuth device authorization: issuer "https://.*" does not support device code grants',
     +            id="missing device code grants",
     +        ),
     +        pytest.param(
     +            (
     +                200,
     +                {
    -+                    "issuer": "https://example.com",
    -+                    "token_endpoint": "https://example.com/token",
    ++                    "issuer": "{issuer}",
    ++                    "token_endpoint": "https://256.256.256.256/token",
     +                    "grant_types_supported": [
     +                        "urn:ietf:params:oauth:grant-type:device_code"
     +                    ],
     +                },
     +            ),
    -+            r'cannot run OAuth device authorization: issuer "https://example.com" does not provide a device authorization endpoint',
    ++            r'cannot run OAuth device authorization: issuer "https://.*" does not provide a device authorization endpoint',
     +            id="missing device_authorization_endpoint",
     +        ),
     +        pytest.param(
     +            (
     +                200,
     +                {
    -+                    "issuer": "https://example.com",
    -+                    "token_endpoint": "https://example.com/token",
    ++                    "issuer": "{issuer}",
    ++                    "token_endpoint": "https://256.256.256.256/token",
     +                    "grant_types_supported": [
     +                        "urn:ietf:params:oauth:grant-type:device_code"
     +                    ],
    -+                    "device_authorization_endpoint": "https://example.com/dev",
    ++                    "device_authorization_endpoint": "https://256.256.256.256/dev",
     +                    "filler": "x" * 1024 * 1024,
     +                },
     +            ),
     +            r"failed to fetch OpenID discovery document: response is too large",
     +            id="gigantic discovery response",
     +        ),
    ++        pytest.param(
    ++            (
    ++                200,
    ++                {
    ++                    "issuer": "{issuer}/path",
    ++                    "token_endpoint": "https://256.256.256.256/token",
    ++                    "grant_types_supported": [
    ++                        "urn:ietf:params:oauth:grant-type:device_code"
    ++                    ],
    ++                    "device_authorization_endpoint": "https://256.256.256.256/dev",
    ++                },
    ++            ),
    ++            r"failed to parse OpenID discovery document: the issuer identifier \(https://.*/path\) does not match oauth_issuer \(https://.*\)",
    ++            id="mismatched issuer identifier",
    ++        ),
     +        #
     +        # Exercise HTTP-level failures by breaking the protocol. Note that the
     +        # error messages here are implementation-dependent.
    @@ src/test/python/client/test_oauth.py (new)
     +    accept, openid_provider, bad_response, expected_error
     +):
     +    sock, client = accept(
    -+        oauth_issuer=openid_provider.issuer,
    ++        oauth_issuer=openid_provider.discovery_uri,
     +        oauth_client_id=secrets.token_hex(),
     +    )
     +
     +    def failing_discovery_handler(headers, params):
    ++        try:
    ++            # Insert the correct issuer value if the test wants to.
    ++            resp = bad_response[1]
    ++            iss = resp["issuer"]
    ++            resp["issuer"] = iss.format(issuer=openid_provider.issuer)
    ++        except (AttributeError, KeyError, TypeError):
    ++            pass
    ++
     +        return bad_response
     +
     +    openid_provider.register_endpoint(
    @@ src/test/python/client/test_oauth.py (new)
     +    ],
     +)
     +def test_oauth_server_error(accept, sasl_err, resp_type, resp_payload, expected_error):
    -+    sock, client = accept()
    ++    sock, client = accept(
    ++        oauth_issuer="https://example.com",
    ++        oauth_client_id="some-id",
    ++    )
     +
     +    with sock:
     +        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
    @@ src/test/python/client/test_oauth.py (new)
     +    int_max = ctypes.c_uint(-1).value // 2
     +
     +    sock, client = accept(
    -+        oauth_issuer=openid_provider.issuer,
    ++        oauth_issuer=openid_provider.discovery_uri,
     +        oauth_client_id=secrets.token_hex(),
     +    )
     +
    @@ src/test/python/client/test_oauth.py (new)
     +    HTTP must be refused without PGOAUTHDEBUG.
     +    """
     +    monkeypatch.delenv("PGOAUTHDEBUG")
    ++
    ++    def to_http(uri):
    ++        """Swaps out a URI's scheme for http."""
    ++        parts = urllib.parse.urlparse(uri)
    ++        parts = parts._replace(scheme="http")
    ++        return urllib.parse.urlunparse(parts)
    ++
     +    sock, client = accept(
    ++        oauth_issuer=to_http(openid_provider.issuer),
     +        oauth_client_id=secrets.token_hex(),
     +    )
     +
    @@ src/test/python/client/test_oauth.py (new)
     +        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
     +            initial = start_oauth_handshake(conn)
     +
    -+            # Fail the SASL exchange and link to the HTTP provider.
     +            resp = {
     +                "status": "invalid_token",
    -+                "openid-configuration": openid_provider.discovery_uri,
    ++                "openid-configuration": to_http(openid_provider.discovery_uri),
     +            }
    ++            pq3.send(
    ++                conn,
    ++                pq3.types.AuthnRequest,
    ++                type=pq3.authn.SASLContinue,
    ++                body=json.dumps(resp).encode("utf-8"),
    ++            )
     +
    -+            fail_oauth_handshake(conn, resp)
    -+
    -+    # FIXME: We'll get a second connection, but it won't do anything.
    -+    sock, _ = accept()
    -+    expect_disconnected_handshake(sock)
    ++            # FIXME: the client disconnects at this point; it'd be nicer if
    ++            # it completed the exchange.
     +
    -+    expected_error = "failed to fetch OpenID discovery document: Unsupported protocol"
    ++    expected_error = r'OAuth discovery URI ".*" must use HTTPS'
     +    with pytest.raises(psycopg2.OperationalError, match=expected_error):
     +        client.check_completed()
     
    @@ src/test/python/meson.build (new)
     +      './test_pq3.py',
     +    ],
     +    'env': pytest_env,
    ++    'test_kwargs': {'priority': 50}, # python tests are slow, start early
     +  },
     +}
     
    @@ src/test/python/server/conftest.py (new)
     +                        f"-c port={port}",
     +                        "-c listen_addresses=localhost",
     +                        "-c log_connections=on",
    -+                        "-c shared_preload_libraries=oauthtest",
    -+                        "-c oauth_validator_library=oauthtest",
    ++                        "-c session_preload_libraries=oauthtest",
    ++                        "-c oauth_validator_libraries=oauthtest",
     +                    ]
     +                ),
     +                "start",
    @@ src/tools/make_venv (new)
     +run(pip, 'install', 'pytest', 'pytest-tap')
     +if args.requirements:
     +    run(pip, 'install', '-r', args.requirements)
    +
    + ## src/tools/testwrap ##
    +@@ src/tools/testwrap: parser.add_argument('--testgroup', help='test group', type=str)
    + parser.add_argument('--testname', help='test name', type=str)
    + parser.add_argument('--skip', help='skip test (with reason)', type=str)
    + parser.add_argument('--pg-test-extra', help='extra tests', type=str)
    ++parser.add_argument('--skip-without-extra', help='skip if PG_TEST_EXTRA is missing this arg', type=str)
    + parser.add_argument('test_command', nargs='*')
    + 
    + args = parser.parse_args()
    +@@ src/tools/testwrap: if args.skip is not None:
    +     print('1..0 # Skipped: ' + args.skip)
    +     sys.exit(0)
    + 
    ++if args.skip_without_extra is not None:
    ++    extras = os.environ.get("PG_TEST_EXTRA", args.pg_test_extra)
    ++    if extras is None or args.skip_without_extra not in extras.split():
    ++        print(f'1..0 # Skipped: PG_TEST_EXTRA does not contain "{args.skip_without_extra}"')
    ++        sys.exit(0)
    ++
    + if os.path.exists(testdir) and os.path.isdir(testdir):
    +     shutil.rmtree(testdir)
    + os.makedirs(testdir)