since-v41.diff.txt

text/plain

Filename: since-v41.diff.txt
Type: text/plain
Part: 0
Message: Re: [PoC] Federated Authn/z with OAUTHBEARER
1:  386e7c4df31 = 1:  1f38ec8039b Move PG_MAX_AUTH_TOKEN_LENGTH to libpq/auth.h
2:  b829f7a8ac7 = 2:  9f87ffea1c7 require_auth: prepare for multiple SASL mechanisms
3:  f88f98df97d ! 3:  bda684d19cc libpq: handle asynchronous actions during SASL
    @@ src/interfaces/libpq/fe-auth.c: pg_SASL_continue(PGconn *conn, int payloadlen, b
     +		 * need to do is signal the caller.
     +		 */
     +		*async = true;
    -+		return STATUS_OK;
    ++
    ++		/*
    ++		 * The mechanism may optionally generate some output to send before
    ++		 * switching over to async auth, so continue onwards.
    ++		 */
     +	}
     +
      	if (final && status == SASL_CONTINUE)
    @@ src/interfaces/libpq/fe-auth.c: pg_fe_sendauth(AuthRequest areq, int payloadlen,
      		case AUTH_REQ_SASL_CONT:
      		case AUTH_REQ_SASL_FIN:
     -			if (conn->sasl_state == NULL)
    --			{
    + 			{
     -				appendPQExpBufferStr(&conn->errorMessage,
     -									 "fe_sendauth: invalid authentication request from server: AUTH_REQ_SASL_CONT without AUTH_REQ_SASL\n");
     -				return STATUS_ERROR;
    @@ src/interfaces/libpq/fe-auth.c: pg_fe_sendauth(AuthRequest areq, int payloadlen,
     -			oldmsglen = conn->errorMessage.len;
     -			if (pg_SASL_continue(conn, payloadlen,
     -								 (areq == AUTH_REQ_SASL_FIN)) != STATUS_OK)
    - 			{
    +-			{
     -				/* Use this message if pg_SASL_continue didn't supply one */
     -				if (conn->errorMessage.len == oldmsglen)
     +				bool		final = false;
4:  d96712cda1d ! 4:  3dc6dd3433c Add OAUTHBEARER SASL mechanism
    @@ Commit message
     
         Several TODOs:
         - perform several sanity checks on the OAuth issuer's responses
    -    - handle cases where the client has been set up with an issuer and
    -      scope, but the Postgres server wants to use something different
         - improve error debuggability during the OAuth handshake
         - fix libcurl initialization thread-safety
         - harden the libcurl flow implementation
    -    - figure out pgsocket/int difference on Windows
         - fill in documentation stubs
         - support protocol "variants" implemented by major providers
         - implement more helpful handling of HBA misconfigurations
    @@ doc/src/sgml/installation.sgml: ninja install
            <listitem>
     
      ## doc/src/sgml/libpq.sgml ##
    +@@ doc/src/sgml/libpq.sgml: postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
    +           </listitem>
    +          </varlistentry>
    + 
    ++         <varlistentry>
    ++          <term><literal>oauth</literal></term>
    ++          <listitem>
    ++           <para>
    ++            The server must request an OAuth bearer token from the client.
    ++           </para>
    ++          </listitem>
    ++         </varlistentry>
    ++
    +          <varlistentry>
    +           <term><literal>none</literal></term>
    +           <listitem>
     @@ doc/src/sgml/libpq.sgml: postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
             </para>
            </listitem>
    @@ doc/src/sgml/libpq.sgml: postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
     +        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 URL, 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
    ++        You may also explicitly set <literal>oauth_issuer</literal> to the
    ++        <literal>/.well-known/</literal> URI used for OAuth discovery. In this
    ++        case, if the server asks for a different URL, the connection will fail,
    ++        but a <link linkend="libpq-oauth-authdata-hooks">custom OAuth flow</link>
    ++        may be able to speed up the standard handshake by using previously
    ++        cached tokens. (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
    @@ doc/src/sgml/libpq.sgml: postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
     +        An OAuth 2.0 client identifier, as issued by the authorization server.
     +        If the <productname>PostgreSQL</productname> server
     +        <link linkend="auth-oauth">requests an OAuth token</link> for the
    -+        connection (and if no <link linkend="libpq-oauth">custom OAuth
    -+        hook</link> is installed to provide one), then this parameter must be
    -+        set; otherwise, the connection will fail.
    ++        connection (and if no <link linkend="libpq-oauth-authdata-hooks">custom
    ++        OAuth hook</link> is installed to provide one), then this parameter must
    ++        be set; otherwise, the connection will fail.
     +       </para>
     +      </listitem>
     +     </varlistentry>
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +				break;
     +
     +			case OAUTH_STEP_TOKEN_REQUEST:
    -+				if (!handle_token_response(actx, &state->token))
    ++				if (!handle_token_response(actx, &conn->oauth_token))
     +					goto error_return;
     +
     +				if (!actx->user_prompted)
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +					actx->user_prompted = true;
     +				}
     +
    -+				if (state->token)
    ++				if (conn->oauth_token)
     +					break;		/* done! */
     +
     +				/*
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		 * point, actx->running will be set. But there are some corner cases
     +		 * where we can immediately loop back around; see start_request().
     +		 */
    -+	} while (!state->token && !actx->running);
    ++	} while (!conn->oauth_token && !actx->running);
     +
     +	/* If we've stored a token, we're done. Otherwise come back later. */
    -+	return state->token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
    ++	return conn->oauth_token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
     +
     +error_return:
     +
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +	/* Any async authentication state should have been cleaned up already. */
     +	Assert(!state->async_ctx);
     +
    -+	free(state->token);
     +	free(state);
     +}
     +
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +/*
     + * Constructs an OAUTHBEARER client initial response (RFC 7628, Sec. 3.1).
     + *
    -+ * If discover is true, the token pointer will be ignored and the initial
    -+ * response will instead contain a request for the server's required OAuth
    -+ * parameters (Sec. 4.3). Otherwise, a bearer token must be provided.
    ++ * If discover is true, the initial response will contain a request for the
    ++ * server's required OAuth parameters (Sec. 4.3). Otherwise, conn->token must
    ++ * be set; it will be sent as the connection's bearer token.
     + *
     + * Returns the response as a null-terminated string, or NULL on error.
     + */
     +static char *
    -+client_initial_response(PGconn *conn, bool discover, const char *token)
    ++client_initial_response(PGconn *conn, bool discover)
     +{
     +	static const char *const resp_format = "n,," kvsep "auth=%s%s" kvsep kvsep;
     +
     +	PQExpBufferData buf;
     +	const char *authn_scheme;
     +	char	   *response = NULL;
    ++	const char *token = conn->oauth_token;
     +
     +	if (discover)
     +	{
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +		 */
     +		authn_scheme = "Bearer ";
     +
    -+		/* We must have a token. */
    ++		/* conn->token must have been set in this case. */
     +		if (!token)
     +		{
    -+			/*
    -+			 * Either programmer error, or something went badly wrong during
    -+			 * the asynchronous fetch.
    -+			 *
    -+			 * TODO: users shouldn't see this; what action should they take if
    -+			 * they do?
    -+			 */
    ++			Assert(false);
     +			libpq_append_conn_error(conn,
    -+									"no OAuth token was set for the connection");
    ++									"internal error: no OAuth token was set for the connection");
     +			return NULL;
     +		}
     +	}
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +/*
     + * Parses the server error result (RFC 7628, Sec. 3.2.2) contained in msg and
     + * stores any discovered openid_configuration and scope settings for the
    -+ * connection. conn->oauth_want_retry will be set if the error status is
    -+ * suitable for a second attempt.
    ++ * connection.
     + */
     +static bool
     +handle_oauth_sasl_error(PGconn *conn, const char *msg, int msglen)
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +	if (errmsg)
     +		goto cleanup;
     +
    -+	/* 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;
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +
     +		free(discovery_issuer);
     +
    -+		if (conn->oauth_discovery_uri)
    -+			free(conn->oauth_discovery_uri);
    -+
    -+		conn->oauth_discovery_uri = ctx.discovery_uri;
    -+		ctx.discovery_uri = NULL;
    ++		if (!conn->oauth_discovery_uri)
    ++		{
    ++			conn->oauth_discovery_uri = ctx.discovery_uri;
    ++			ctx.discovery_uri = NULL;
    ++		}
    ++		else
    ++		{
    ++			/* This must match the URI we'd previously determined. */
    ++			if (strcmp(conn->oauth_discovery_uri, ctx.discovery_uri) != 0)
    ++			{
    ++				libpq_append_conn_error(conn,
    ++										"server's discovery document has moved to %s (previous location was %s)",
    ++										ctx.discovery_uri,
    ++										conn->oauth_discovery_uri);
    ++				goto cleanup;
    ++			}
    ++		}
     +	}
     +
     +	if (ctx.scope)
     +	{
    -+		if (conn->oauth_scope)
    -+			free(conn->oauth_scope);
    -+
    -+		conn->oauth_scope = ctx.scope;
    -+		ctx.scope = NULL;
    ++		/* Servers may not override a previously set oauth_scope. */
    ++		if (!conn->oauth_scope)
    ++		{
    ++			conn->oauth_scope = ctx.scope;
    ++			ctx.scope = NULL;
    ++		}
     +	}
    -+	/* TODO: missing error scope should clear any existing connection scope */
     +
     +	if (!ctx.status)
     +	{
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +		goto cleanup;
     +	}
     +
    -+	if (strcmp(ctx.status, "invalid_token") == 0)
    ++	if (strcmp(ctx.status, "invalid_token") != 0)
     +	{
     +		/*
    -+		 * invalid_token is the only error code we'll automatically retry for,
    -+		 * but only if we have enough information to do so and we haven't
    -+		 * already retried this connection once.
    ++		 * invalid_token is the only error code we'll automatically retry for;
    ++		 * otherwise, just bail out now.
     +		 */
    -+		if (conn->oauth_discovery_uri && conn->oauth_want_retry == PG_BOOL_UNKNOWN)
    -+			conn->oauth_want_retry = PG_BOOL_YES;
    ++		libpq_append_conn_error(conn,
    ++								"server rejected OAuth bearer token: %s",
    ++								ctx.status);
    ++		goto cleanup;
     +	}
    -+	/* TODO: include status in hard failure message */
     +
     +	success = true;
     +
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +	else if (status == PGRES_POLLING_OK)
     +	{
     +		/*
    -+		 * We already have a token, so copy it into the state. (We can't hold
    ++		 * We already have a token, so copy it into the conn. (We can't hold
     +		 * onto the original string, since it may not be safe for us to free()
     +		 * it.)
     +		 */
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +			return PGRES_POLLING_FAILED;
     +		}
     +
    -+		state->token = strdup(request->token);
    -+		if (!state->token)
    ++		conn->oauth_token = strdup(request->token);
    ++		if (!conn->oauth_token)
     +		{
     +			libpq_append_conn_error(conn, "out of memory");
     +			return PGRES_POLLING_FAILED;
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +		return PGRES_POLLING_OK;
     +	}
     +
    -+	/* TODO: what if no altsock was set? */
    ++	/* The hook wants the client to poll the altsock. Make sure it set one. */
    ++	if (conn->altsock == PGINVALID_SOCKET)
    ++	{
    ++		libpq_append_conn_error(conn,
    ++								"user-defined OAuth flow did not provide a socket for polling");
    ++		return PGRES_POLLING_FAILED;
    ++	}
    ++
     +	return status;
     +}
     +
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +		if (request.token)
     +		{
     +			/*
    -+			 * We already have a token, so copy it into the state. (We can't
    ++			 * We already have a token, so copy it into the conn. (We can't
     +			 * hold onto the original string, since it may not be safe for us
     +			 * to free() it.)
     +			 */
    -+			state->token = strdup(request.token);
    -+			if (!state->token)
    ++			conn->oauth_token = strdup(request.token);
    ++			if (!conn->oauth_token)
     +			{
     +				libpq_append_conn_error(conn, "out of memory");
     +				goto fail;
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +	else
     +	{
     +#if USE_LIBCURL
    -+		/*
    -+		 * Hand off to our built-in OAuth flow.
    -+		 *
    -+		 * Only allow one try per connection, since we're not performing any
    -+		 * caching at the moment. (Custom flows might be more sophisticated.)
    -+		 */
    ++		/* Hand off to our built-in OAuth flow. */
     +		conn->async_auth = pg_fe_run_oauth_flow;
     +		conn->cleanup_async_auth = pg_fe_cleanup_oauth_flow;
    -+		conn->oauth_want_retry = PG_BOOL_NO;
     +
     +#else
     +		libpq_append_conn_error(conn, "no custom OAuth flows are available, and libpq was not built with libcurl support");
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +			if (!setup_oauth_parameters(conn))
     +				return SASL_FAILED;
     +
    -+			if (conn->oauth_discovery_uri)
    ++			if (conn->oauth_token)
     +			{
     +				/*
    -+				 * Decide whether we're using a user-provided OAuth flow, or
    -+				 * the one we have built in.
    ++				 * A previous connection already fetched the token; we'll use
    ++				 * it below.
    ++				 */
    ++			}
    ++			else if (conn->oauth_discovery_uri)
    ++			{
    ++				/*
    ++				 * We don't have a token, but we have a discovery URI already
    ++				 * stored. Decide whether we're using a user-provided OAuth
    ++				 * flow or the one we have built in.
     +				 */
     +				if (!setup_token_request(conn, state))
     +					return SASL_FAILED;
     +
    -+				if (state->token)
    ++				if (conn->oauth_token)
     +				{
     +					/*
     +					 * A really smart user implementation may have already
     +					 * given us the token (e.g. if there was an unexpired copy
    -+					 * already cached). In that case, we can just fall
    -+					 * through.
    ++					 * already cached), and we can use it immediately.
     +					 */
     +				}
     +				else
     +				{
     +					/*
    -+					 * Otherwise, we have to hand the connection over to our
    -+					 * OAuth implementation. This involves a number of HTTP
    -+					 * connections and timed waits, so we escape the
    -+					 * synchronous auth processing and tell PQconnectPoll to
    -+					 * transfer control to our async implementation.
    ++					 * Otherwise, we'll have to hand the connection over to
    ++					 * our OAuth implementation.
    ++					 *
    ++					 * This could take a while, since it generally involves a
    ++					 * user in the loop. To avoid consuming the server's
    ++					 * authentication timeout, we'll continue this handshake
    ++					 * to the end, so that the server can close its side of
    ++					 * the connection. We'll open a second connection later
    ++					 * once we've retrieved a token.
     +					 */
    -+					Assert(conn->async_auth);	/* should have been set
    -+												 * already */
    -+					state->step = FE_OAUTH_REQUESTING_TOKEN;
    -+					return SASL_ASYNC;
    ++					discover = true;
     +				}
     +			}
     +			else
     +			{
     +				/*
    -+				 * If we don't have a discovery URI to be able to request a
    -+				 * token, we ask the server for one explicitly. This doesn't
    -+				 * require any asynchronous work.
    ++				 * If we don't have a token, and we don't have a discovery URI
    ++				 * to be able to request a token, we ask the server for one
    ++				 * explicitly.
     +				 */
     +				discover = true;
     +			}
     +
    -+			/* fall through */
    -+
    -+		case FE_OAUTH_REQUESTING_TOKEN:
    -+			/* We should still be in the initial response phase. */
    -+			Assert(inputlen == -1);
    -+
    -+			*output = client_initial_response(conn, discover, state->token);
    ++			/*
    ++			 * Generate an initial response. This either contains a token, if
    ++			 * we have one, or an empty discovery response which is doomed to
    ++			 * fail.
    ++			 */
    ++			*output = client_initial_response(conn, discover);
     +			if (!*output)
     +				return SASL_FAILED;
     +
     +			*outputlen = strlen(*output);
     +			state->step = FE_OAUTH_BEARER_SENT;
     +
    ++			if (conn->oauth_token)
    ++			{
    ++				/*
    ++				 * For the purposes of require_auth, our side of
    ++				 * authentication is done at this point; the server will
    ++				 * either accept the connection or send an error. Unlike
    ++				 * SCRAM, there is no additional server data to check upon
    ++				 * success.
    ++				 */
    ++				conn->client_finished_auth = true;
    ++			}
    ++
     +			return SASL_CONTINUE;
     +
     +		case FE_OAUTH_BEARER_SENT:
     +			if (final)
     +			{
    -+				/* TODO: ensure there is no message content here. */
    -+				return SASL_COMPLETE;
    -+			}
    -+
    -+			/*
    -+			 * Error message sent by the server.
    -+			 */
    -+			if (!handle_oauth_sasl_error(conn, input, inputlen))
    ++				/*
    ++				 * OAUTHBEARER does not make use of additional data with a
    ++				 * successful SASL exchange, so we shouldn't get an
    ++				 * AuthenticationSASLFinal message.
    ++				 */
    ++				libpq_append_conn_error(conn,
    ++										"server sent unexpected additional OAuth data");
     +				return SASL_FAILED;
    ++			}
     +
     +			/*
    -+			 * Respond with the required dummy message (RFC 7628, sec. 3.2.3).
    ++			 * An error message was sent by the server. Respond with the
    ++			 * required dummy message (RFC 7628, sec. 3.2.3).
     +			 */
     +			*output = strdup(kvsep);
     +			if (unlikely(!*output))
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +			}
     +			*outputlen = strlen(*output);	/* == 1 */
     +
    -+			state->step = FE_OAUTH_SERVER_ERROR;
    -+			return SASL_CONTINUE;
    ++			/* Grab the settings from discovery. */
    ++			if (!handle_oauth_sasl_error(conn, input, inputlen))
    ++				return SASL_FAILED;
    ++
    ++			if (conn->oauth_token)
    ++			{
    ++				/*
    ++				 * The server rejected our token. Continue onwards towards the
    ++				 * expected FATAL message, but mark our state to catch any
    ++				 * unexpected "success" from the server.
    ++				 */
    ++				state->step = FE_OAUTH_SERVER_ERROR;
    ++				return SASL_CONTINUE;
    ++			}
    ++
    ++			if (!conn->async_auth)
    ++			{
    ++				/*
    ++				 * No OAuth flow is set up yet. Did we get enough information
    ++				 * from the server to create one?
    ++				 */
    ++				if (!conn->oauth_discovery_uri)
    ++				{
    ++					libpq_append_conn_error(conn,
    ++											"server requires OAuth authentication, but no discovery metadata was provided");
    ++					return SASL_FAILED;
    ++				}
    ++
    ++				/* Yes. Set up the flow now. */
    ++				if (!setup_token_request(conn, state))
    ++					return SASL_FAILED;
    ++
    ++				if (conn->oauth_token)
    ++				{
    ++					/*
    ++					 * A token was available in a custom flow's cache. Skip
    ++					 * the asynchronous processing.
    ++					 */
    ++					goto reconnect;
    ++				}
    ++			}
    ++
    ++			/*
    ++			 * Time to retrieve a token. This involves a number of HTTP
    ++			 * connections and timed waits, so we escape the synchronous auth
    ++			 * processing and tell PQconnectPoll to transfer control to our
    ++			 * async implementation.
    ++			 */
    ++			Assert(conn->async_auth);	/* should have been set already */
    ++			state->step = FE_OAUTH_REQUESTING_TOKEN;
    ++			return SASL_ASYNC;
    ++
    ++		case FE_OAUTH_REQUESTING_TOKEN:
    ++
    ++			/*
    ++			 * We've returned successfully from token retrieval. Double-check
    ++			 * that we have what we need for the next connection.
    ++			 */
    ++			if (!conn->oauth_token)
    ++			{
    ++				Assert(false);	/* should have failed before this point! */
    ++				libpq_append_conn_error(conn,
    ++										"internal error: OAuth flow did not set a token");
    ++				return SASL_FAILED;
    ++			}
    ++
    ++			goto reconnect;
     +
     +		case FE_OAUTH_SERVER_ERROR:
     +
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +			break;
     +	}
     +
    ++	Assert(false);				/* should never get here */
    ++	return SASL_FAILED;
    ++
    ++reconnect:
    ++
    ++	/*
    ++	 * Despite being a failure from the point of view of SASL, we have enough
    ++	 * information to restart with a new connection.
    ++	 */
    ++	libpq_append_conn_error(conn, "retrying connection with new bearer token");
    ++	conn->oauth_want_retry = true;
     +	return SASL_FAILED;
     +}
     +
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +}
     +
     +/*
    ++ * Fully clears out any stored OAuth token. This is done proactively upon
    ++ * successful connection as well as during pqClosePGconn().
    ++ */
    ++void
    ++pqClearOAuthToken(PGconn *conn)
    ++{
    ++	if (!conn->oauth_token)
    ++		return;
    ++
    ++	explicit_bzero(conn->oauth_token, strlen(conn->oauth_token));
    ++	free(conn->oauth_token);
    ++	conn->oauth_token = NULL;
    ++}
    ++
    ++/*
     + * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment.
     + */
     +bool
    @@ src/interfaces/libpq/fe-auth-oauth.h (new)
     +enum fe_oauth_step
     +{
     +	FE_OAUTH_INIT,
    -+	FE_OAUTH_REQUESTING_TOKEN,
     +	FE_OAUTH_BEARER_SENT,
    ++	FE_OAUTH_REQUESTING_TOKEN,
     +	FE_OAUTH_SERVER_ERROR,
     +};
     +
    @@ src/interfaces/libpq/fe-auth-oauth.h (new)
     +	enum fe_oauth_step step;
     +
     +	PGconn	   *conn;
    -+	char	   *token;
    -+
     +	void	   *async_ctx;
     +} fe_oauth_state;
     +
     +extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
     +extern void pg_fe_cleanup_oauth_flow(PGconn *conn);
    ++extern void pqClearOAuthToken(PGconn *conn);
     +extern bool oauth_unsafe_debugging_enabled(void);
     +
     +/* Mechanisms in fe-auth-oauth.c */
    @@ src/interfaces/libpq/fe-auth.c: pg_SASL_init(PGconn *conn, int payloadlen, bool
      	}
      
      	if (!selected_mechanism)
    +@@ src/interfaces/libpq/fe-auth.c: pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
    + 
    + 		if (!allowed)
    + 		{
    +-			/*
    +-			 * TODO: this is dead code until a second SASL mechanism is added;
    +-			 * the connection can't have proceeded past check_expected_areq()
    +-			 * if no SASL methods are allowed.
    +-			 */
    +-			Assert(false);
    +-
    + 			libpq_append_conn_error(conn, "authentication method requirement \"%s\" failed: server requested %s authentication",
    + 									conn->require_auth, selected_mechanism);
    + 			goto error;
     @@ src/interfaces/libpq/fe-auth.c: PQchangePassword(PGconn *conn, const char *user, const char *passwd)
      		}
      	}
    @@ src/interfaces/libpq/fe-connect.c
      #include "libpq-int.h"
      #include "mb/pg_wchar.h"
     @@ src/interfaces/libpq/fe-connect.c: static const internalPQconninfoOption PQconninfoOptions[] = {
    - 		"Load-Balance-Hosts", "", 8,	/* sizeof("disable") = 8 */
    - 	offsetof(struct pg_conn, load_balance_hosts)},
    + 	{"scram_server_key", NULL, NULL, NULL, "SCRAM-Server-Key", "D", SCRAM_MAX_KEY_LEN * 2,
    + 	offsetof(struct pg_conn, scram_server_key)},
      
     +	/* OAuth v2 */
     +	{"oauth_issuer", NULL, NULL, NULL,
    @@ src/interfaces/libpq/fe-connect.c: pqDropServerData(PGconn *conn)
      	conn->write_failed = false;
      	free(conn->write_err_msg);
      	conn->write_err_msg = NULL;
    -+	/* conn->oauth_want_retry = false; TODO */
    ++	conn->oauth_want_retry = false;
      
      	/*
      	 * Cancel connections need to retain their be_pid and be_key across
    +@@ src/interfaces/libpq/fe-connect.c: static inline void
    + fill_allowed_sasl_mechs(PGconn *conn)
    + {
    + 	/*---
    +-	 * We only support one mechanism at the moment, so rather than deal with a
    ++	 * We only support two mechanisms at the moment, so rather than deal with a
    + 	 * linked list, conn->allowed_sasl_mechs is an array of static length. We
    + 	 * rely on the compile-time assertion here to keep us honest.
    + 	 *
    +@@ src/interfaces/libpq/fe-connect.c: fill_allowed_sasl_mechs(PGconn *conn)
    + 	 * - handle the new mechanism name in the require_auth portion of
    + 	 *   pqConnectOptions2(), below.
    + 	 */
    +-	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == 1,
    ++	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == 2,
    + 					 "fill_allowed_sasl_mechs() must be updated when resizing conn->allowed_sasl_mechs[]");
    + 
    + 	conn->allowed_sasl_mechs[0] = &pg_scram_mech;
    ++	conn->allowed_sasl_mechs[1] = &pg_oauth_mech;
    + }
    + 
    + /*
    +@@ src/interfaces/libpq/fe-connect.c: pqConnectOptions2(PGconn *conn)
    + 			{
    + 				mech = &pg_scram_mech;
    + 			}
    ++			else if (strcmp(method, "oauth") == 0)
    ++			{
    ++				mech = &pg_oauth_mech;
    ++			}
    + 
    + 			/*
    + 			 * Final group: meta-options.
     @@ src/interfaces/libpq/fe-connect.c: keep_going:						/* We will come back to here until there is
    - 					/* Check to see if we should mention pgpassfile */
    - 					pgpassfileWarning(conn);
    + 				conn->inStart = conn->inCursor;
      
    + 				if (res != STATUS_OK)
    ++				{
     +					/*
     +					 * OAuth connections may perform two-step discovery, where
     +					 * the first connection is a dummy.
     +					 */
    -+					if (conn->sasl == &pg_oauth_mech
    -+						&& conn->oauth_want_retry == PG_BOOL_YES)
    ++					if (conn->sasl == &pg_oauth_mech && conn->oauth_want_retry)
     +					{
    -+						/* Only allow retry once. */
    -+						conn->oauth_want_retry = PG_BOOL_NO;
     +						need_new_connection = true;
     +						goto keep_going;
     +					}
     +
    - 					CONNECTION_FAILED();
    + 					goto error_return;
    ++				}
    + 
    + 				/*
    + 				 * Just make sure that any data sent by pg_fe_sendauth is
    +@@ src/interfaces/libpq/fe-connect.c: keep_going:						/* We will come back to here until there is
    + 					}
      				}
    - 				else if (beresp == PqMsg_NegotiateProtocolVersion)
    + 
    ++				/* Don't hold onto any OAuth tokens longer than necessary. */
    ++				pqClearOAuthToken(conn);
    ++
    + 				/*
    + 				 * For non cancel requests we can release the address list
    + 				 * now. For cancel requests we never actually resolve
     @@ src/interfaces/libpq/fe-connect.c: freePGconn(PGconn *conn)
    - 	free(conn->rowBuf);
    - 	free(conn->target_session_attrs);
      	free(conn->load_balance_hosts);
    + 	free(conn->scram_client_key);
    + 	free(conn->scram_server_key);
     +	free(conn->oauth_issuer);
     +	free(conn->oauth_issuer_id);
     +	free(conn->oauth_discovery_uri);
    @@ src/interfaces/libpq/fe-connect.c: freePGconn(PGconn *conn)
      	termPQExpBuffer(&conn->errorMessage);
      	termPQExpBuffer(&conn->workBuffer);
      
    +@@ src/interfaces/libpq/fe-connect.c: pqClosePGconn(PGconn *conn)
    + 	conn->asyncStatus = PGASYNC_IDLE;
    + 	conn->xactStatus = PQTRANS_IDLE;
    + 	conn->pipelineStatus = PQ_PIPELINE_OFF;
    ++	pqClearOAuthToken(conn);
    + 	pqClearAsyncResult(conn);	/* deallocate result */
    + 	pqClearConnErrorState(conn);
    + 
     
      ## src/interfaces/libpq/libpq-fe.h ##
     @@ src/interfaces/libpq/libpq-fe.h: extern "C"
    @@ src/interfaces/libpq/libpq-int.h: struct pg_conn
     +	char	   *oauth_client_id;	/* client identifier */
     +	char	   *oauth_client_secret;	/* client secret */
     +	char	   *oauth_scope;	/* access token scope */
    -+	PGTernaryBool oauth_want_retry; /* should we retry on failure? */
    ++	char	   *oauth_token;	/* access token */
    ++	bool		oauth_want_retry;	/* should we retry on failure? */
     +
      	/* Optional file to write trace info to */
      	FILE	   *Pfdebug;
      	int			traceFlags;
    +@@ src/interfaces/libpq/libpq-int.h: struct pg_conn
    + 								 * the server? */
    + 	uint32		allowed_auth_methods;	/* bitmask of acceptable AuthRequest
    + 										 * codes */
    +-	const pg_fe_sasl_mech *allowed_sasl_mechs[1];	/* and acceptable SASL
    ++	const pg_fe_sasl_mech *allowed_sasl_mechs[2];	/* and acceptable SASL
    + 													 * mechanisms */
    + 	bool		client_finished_auth;	/* have we finished our half of the
    + 										 * authentication exchange? */
     
      ## src/interfaces/libpq/meson.build ##
     @@
    @@ src/makefiles/meson.build: pgxs_deps = {
        'libxslt': libxslt,
        'llvm': llvm,
     
    + ## src/test/authentication/t/001_password.pl ##
    +@@ src/test/authentication/t/001_password.pl: $node->connect_fails(
    + $node->connect_fails(
    + 	"user=scram_role require_auth=!scram-sha-256",
    + 	"SCRAM authentication forbidden, fails with SCRAM auth",
    +-	expected_stderr => qr/server requested SASL authentication/);
    ++	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
    + $node->connect_fails(
    + 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
    + 	"multiple authentication types forbidden, fails with SCRAM auth",
    +-	expected_stderr => qr/server requested SASL authentication/);
    ++	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
    + 
    + # Test that bad passwords are rejected.
    + $ENV{"PGPASSWORD"} = 'badpass';
    +@@ src/test/authentication/t/001_password.pl: $node->connect_fails(
    + 	"user=scram_role require_auth=!scram-sha-256",
    + 	"password authentication forbidden, fails with SCRAM auth",
    + 	expected_stderr =>
    +-	  qr/authentication method requirement "!scram-sha-256" failed: server requested SASL authentication/
    ++	  qr/authentication method requirement "!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
    + );
    + $node->connect_fails(
    + 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
    + 	"multiple authentication types forbidden, fails with SCRAM auth",
    + 	expected_stderr =>
    +-	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SASL authentication/
    ++	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
    + );
    + 
    + # Test SYSTEM_USER <> NULL with parallel workers.
    +
      ## src/test/modules/Makefile ##
     @@ src/test/modules/Makefile: SUBDIRS = \
      		  dummy_index_am \
    @@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
     +static PostgresPollingStatusType async_cb(PGconn *conn,
     +										  PGoauthBearerRequest *req,
     +										  pgsocket *altsock);
    ++static PostgresPollingStatusType misbehave_cb(PGconn *conn,
    ++											  PGoauthBearerRequest *req,
    ++											  pgsocket *altsock);
     +
     +static void
     +usage(char *argv[])
    @@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
     +	printf(" -h, --help				show this message\n");
     +	printf(" --expected-scope SCOPE	fail if received scopes do not match SCOPE\n");
     +	printf(" --expected-uri URI		fail if received configuration link does not match URI\n");
    ++	printf(" --misbehave=MODE		have the hook fail required postconditions\n"
    ++		   "						(MODEs: no-hook, fail-async, no-token, no-socket)\n");
     +	printf(" --no-hook				don't install OAuth hooks (connection will fail)\n");
     +	printf(" --hang-forever			don't ever return a token (combine with connect_timeout)\n");
     +	printf(" --token TOKEN			use the provided TOKEN value\n");
    @@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
     +static bool hang_forever = false;
     +static const char *expected_uri = NULL;
     +static const char *expected_scope = NULL;
    ++static const char *misbehave_mode = NULL;
     +static char *token = NULL;
     +
     +int
    @@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
     +		{"no-hook", no_argument, NULL, 1002},
     +		{"token", required_argument, NULL, 1003},
     +		{"hang-forever", no_argument, NULL, 1004},
    ++		{"misbehave", required_argument, NULL, 1005},
     +		{0}
     +	};
     +
    @@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
     +				hang_forever = true;
     +				break;
     +
    ++			case 1005:			/* --misbehave */
    ++				misbehave_mode = optarg;
    ++				break;
    ++
     +			default:
     +				usage(argv);
     +				return 1;
    @@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
     +		return 1;
     +	}
     +
    ++	if (misbehave_mode)
    ++	{
    ++		if (strcmp(misbehave_mode, "no-hook") != 0)
    ++			req->async = misbehave_cb;
    ++		return 1;
    ++	}
    ++
     +	if (expected_uri)
     +	{
     +		if (!req->openid_configuration)
    @@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
     +
     +	req->token = token;
     +	return PGRES_POLLING_OK;
    ++}
    ++
    ++static PostgresPollingStatusType
    ++misbehave_cb(PGconn *conn, PGoauthBearerRequest *req, pgsocket *altsock)
    ++{
    ++	if (strcmp(misbehave_mode, "fail-async") == 0)
    ++	{
    ++		/* Just fail "normally". */
    ++		return PGRES_POLLING_FAILED;
    ++	}
    ++	else if (strcmp(misbehave_mode, "no-token") == 0)
    ++	{
    ++		/* Callbacks must assign req->token before returning OK. */
    ++		return PGRES_POLLING_OK;
    ++	}
    ++	else if (strcmp(misbehave_mode, "no-socket") == 0)
    ++	{
    ++		/* Callbacks must assign *altsock before asking for polling. */
    ++		return PGRES_POLLING_READING;
    ++	}
    ++	else
    ++	{
    ++		fprintf(stderr, "unrecognized --misbehave mode: %s\n", misbehave_mode);
    ++		exit(1);
    ++	}
     +}
     
      ## src/test/modules/oauth_validator/t/001_server.pl (new) ##
    @@ src/test/modules/oauth_validator/t/001_server.pl (new)
     +	  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\)@
     +);
     +
    ++# Test require_auth settings against OAUTHBEARER.
    ++my @cases = (
    ++	{ require_auth => "oauth" },
    ++	{ require_auth => "oauth,scram-sha-256" },
    ++	{ require_auth => "password,oauth" },
    ++	{ require_auth => "none,oauth" },
    ++	{ require_auth => "!scram-sha-256" },
    ++	{ require_auth => "!none" },
    ++
    ++	{
    ++		require_auth => "!oauth",
    ++		failure => qr/server requested OAUTHBEARER authentication/
    ++	},
    ++	{
    ++		require_auth => "scram-sha-256",
    ++		failure => qr/server requested OAUTHBEARER authentication/
    ++	},
    ++	{
    ++		require_auth => "!password,!oauth",
    ++		failure => qr/server requested OAUTHBEARER authentication/
    ++	},
    ++	{
    ++		require_auth => "none",
    ++		failure => qr/server requested SASL authentication/
    ++	},
    ++	{
    ++		require_auth => "!oauth,!scram-sha-256",
    ++		failure => qr/server requested SASL authentication/
    ++	});
    ++
    ++$user = "test";
    ++foreach my $c (@cases)
    ++{
    ++	my $connstr =
    ++	  "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635 require_auth=$c->{'require_auth'}";
    ++
    ++	if (defined $c->{'failure'})
    ++	{
    ++		$node->connect_fails(
    ++			$connstr,
    ++			"require_auth=$c->{'require_auth'} fails",
    ++			expected_stderr => $c->{'failure'});
    ++	}
    ++	else
    ++	{
    ++		$node->connect_ok(
    ++			$connstr,
    ++			"require_auth=$c->{'require_auth'} succeeds",
    ++			expected_stderr =>
    ++			  qr@Visit https://example\.com/ and enter the code: postgresuser@
    ++		);
    ++	}
    ++}
    ++
     +# 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_issuer=$issuer/alternate oauth_client_id='$vschars_esc'",
    ++	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc'",
     +	"escapable characters: client_id",
     +	expected_stderr =>
    -+	  qr@Visit https://example\.org/ and enter the code: postgresuser@);
    ++	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
     +$node->connect_ok(
    -+	"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'",
    ++	"user=$user dbname=postgres oauth_issuer=$issuer 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@);
    ++	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
     +
     +#
     +# Further tests rely on support for specific behaviors in oauth_server.py. To
    @@ src/test/modules/oauth_validator/t/001_server.pl (new)
     +$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"
    ++local all testalt oauth validator=fail_validator issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
     +});
     +$node->restart;
     +
    @@ src/test/modules/oauth_validator/t/002_client.pl (new)
     +	flags => ["--hang-forever"],
     +	expected_stderr => qr/failed: timeout expired/);
     +
    ++# Test various misbehaviors of the client hook.
    ++my @cases = (
    ++	{
    ++		flag => "--misbehave=no-hook",
    ++		expected_error =>
    ++		  qr/user-defined OAuth flow provided neither a token nor an async callback/,
    ++	},
    ++	{
    ++		flag => "--misbehave=fail-async",
    ++		expected_error => qr/user-defined OAuth flow failed/,
    ++	},
    ++	{
    ++		flag => "--misbehave=no-token",
    ++		expected_error => qr/user-defined OAuth flow did not provide a token/,
    ++	},
    ++	{
    ++		flag => "--misbehave=no-socket",
    ++		expected_error =>
    ++		  qr/user-defined OAuth flow did not provide a socket for polling/,
    ++	});
    ++
    ++foreach my $c (@cases)
    ++{
    ++	test(
    ++		"hook misbehavior: $c->{'flag'}",
    ++		flags => [ $c->{'flag'} ],
    ++		expected_stderr => $c->{'expected_error'});
    ++}
    ++
     +done_testing();
     
      ## src/test/modules/oauth_validator/t/OAuth/Server.pm (new) ##
5:  18507c6978b < -:  ----------- squash! Add OAUTHBEARER SASL mechanism
6:  8e82059700b = 5:  97e0a2aae26 XXX fix libcurl link error
7:  5339b3f2617 ! 6:  db0167009b9 DO NOT MERGE: Add pytest suite for OAuth
    @@ src/test/python/client/conftest.py (new)
     +            client.start()
     +
     +        sock, _ = server_socket.accept()
    ++        sock.settimeout(BLOCKING_TIMEOUT)
     +        return sock, client
     +
     +    yield factory
    @@ src/test/python/client/test_oauth.py (new)
     +    )
     +
     +
    ++def handle_discovery_connection(sock, discovery=None, *, response=None):
    ++    """
    ++    Helper for all tests that expect an initial discovery connection from the
    ++    client. The provided discovery URI will be used in a standard error response
    ++    from the server (or response may be set, to provide a custom dictionary),
    ++    and the SASL exchange will be failed.
    ++
    ++    By default, the client is expected to complete the entire handshake. Set
    ++    finish to False if the client should immediately disconnect when it receives
    ++    the error response.
    ++    """
    ++    if response is None:
    ++        response = {"status": "invalid_token"}
    ++        if discovery is not None:
    ++            response["openid-configuration"] = discovery
    ++
    ++    with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
    ++        # Initiate a handshake.
    ++        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""
    ++
    ++        # The discovery handshake is doomed to fail.
    ++        fail_oauth_handshake(conn, response)
    ++
    ++
     +class RawResponse(str):
     +    """
     +    Returned by registered endpoint callbacks to take full control of the
    @@ src/test/python/client/test_oauth.py (new)
     +        libpq.PQsetAuthDataHook(None)
     +
     +
    -+@pytest.mark.parametrize("success", [True, False])
    ++@pytest.mark.parametrize(
    ++    "success, abnormal_failure",
    ++    [
    ++        pytest.param(True, False, id="success"),
    ++        pytest.param(False, False, id="normal failure"),
    ++        pytest.param(False, True, id="abnormal failure"),
    ++    ],
    ++)
     +@pytest.mark.parametrize("secret", [None, "", "hunter2"])
     +@pytest.mark.parametrize("scope", [None, "", "openid email"])
     +@pytest.mark.parametrize("retries", [0, 1])
    @@ src/test/python/client/test_oauth.py (new)
     +    secret,
     +    auth_data_cb,
     +    success,
    ++    abnormal_failure,
     +):
     +    client_id = secrets.token_hex()
     +    openid_provider.content_type = content_type
    @@ src/test/python/client/test_oauth.py (new)
     +        "token_endpoint", "POST", "/token", token_endpoint
     +    )
     +
    ++    # First connection is a discovery request, which should result in the above
    ++    # endpoints being called.
    ++    with sock:
    ++        handle_discovery_connection(sock, openid_provider.discovery_uri)
    ++
    ++    # Client should reconnect.
    ++    sock, _ = accept()
     +    with sock:
     +        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
    -+            # Initiate a handshake, which should result in the above endpoints
    -+            # being called.
     +            initial = start_oauth_handshake(conn)
     +
     +            # Validate and accept the token.
    @@ src/test/python/client/test_oauth.py (new)
     +            assert auth == f"Bearer {access_token}".encode("ascii")
     +
     +            if success:
    -+                pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal)
     +                finish_handshake(conn)
     +
    ++            elif abnormal_failure:
    ++                # Send an empty error response, which should result in a
    ++                # mechanism-level failure in the client. This test ensures that
    ++                # the client doesn't try a third connection for this case.
    ++                expected_error = "server sent error response without a status"
    ++                fail_oauth_handshake(conn, {})
    ++
     +            else:
     +                # Simulate token validation failure.
     +                resp = {
    @@ src/test/python/client/test_oauth.py (new)
     +                    "openid-configuration": openid_provider.discovery_uri,
     +                }
     +                expected_error = "test token validation failure"
    -+
     +                fail_oauth_handshake(conn, resp, errmsg=expected_error)
     +
     +    if retries:
    @@ src/test/python/client/test_oauth.py (new)
     +
     +    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:
    ++        handle_discovery_connection(sock, discovery_uri)
     +
    ++    # Expect the client to connect again.
    ++    sock, _ = accept()
     +    with sock:
     +        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
     +            initial = start_oauth_handshake(conn)
    @@ src/test/python/client/test_oauth.py (new)
     +            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)
     +
     +
    @@ src/test/python/client/test_oauth.py (new)
     +            "{issuer}",
     +            "/.well-known/oauth-authorization-server/",
     +            None,
    -+            id="extra empty segment",
    ++            id="extra empty segment (no path)",
    ++        ),
    ++        pytest.param(
    ++            "{issuer}/path",
    ++            "/.well-known/oauth-authorization-server/path/",
    ++            None,
    ++            id="extra empty segment (with path)",
     +        ),
     +        pytest.param(
     +            "{issuer}",
    @@ src/test/python/client/test_oauth.py (new)
     +        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)
    ++    with sock:
    ++        if expected_error and not server_discovery:
    ++            # If the client already knows the URL, it should disconnect as soon
    ++            # as it realizes it's not valid.
    ++            expect_disconnected_handshake(sock)
    ++        else:
    ++            # Otherwise, it should complete the connection.
    ++            handle_discovery_connection(sock, discovery_uri)
    ++
    ++    # The client should not reconnect.
     +
     +    if expected_error is None:
     +        if server_discovery:
    @@ src/test/python/client/test_oauth.py (new)
     +    the client to have an oauth_issuer set so that it doesn't try to go through
     +    discovery.
     +    """
    -+    with sock:
    -+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
    -+            # Initiate a handshake.
    -+            startup = pq3.recv1(conn, cls=pq3.Startup)
    -+            assert startup.proto == pq3.protocol(3, 0)
    ++    with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
    ++        # Initiate a handshake.
    ++        startup = pq3.recv1(conn, cls=pq3.Startup)
    ++        assert startup.proto == pq3.protocol(3, 0)
     +
    -+            pq3.send(
    -+                conn,
    -+                pq3.types.AuthnRequest,
    -+                type=pq3.authn.SASL,
    -+                body=[b"OAUTHBEARER", b""],
    -+            )
    ++        pq3.send(
    ++            conn,
    ++            pq3.types.AuthnRequest,
    ++            type=pq3.authn.SASL,
    ++            body=[b"OAUTHBEARER", b""],
    ++        )
     +
    -+            # The client should disconnect at this point.
    -+            assert not conn.read(1), "client sent unexpected data"
    ++        # The client should disconnect at this point.
    ++        assert not conn.read(1), "client sent unexpected data"
     +
     +
     +@pytest.mark.parametrize(
    @@ src/test/python/client/test_oauth.py (new)
     +        del params[k]
     +
     +    sock, client = accept(**params)
    -+    expect_disconnected_handshake(sock)
    ++    with sock:
    ++        expect_disconnected_handshake(sock)
     +
     +    expected_error = "oauth_issuer and oauth_client_id are not both set"
     +    with pytest.raises(psycopg2.OperationalError, match=expected_error):
    @@ src/test/python/client/test_oauth.py (new)
     +        "token_endpoint", "POST", "/token", token_endpoint
     +    )
     +
    ++    # First connection is a discovery request, which should result in the above
    ++    # endpoints being called.
    ++    with sock:
    ++        handle_discovery_connection(sock, openid_provider.discovery_uri)
    ++
    ++    # Second connection sends the token.
    ++    sock, _ = accept()
     +    with sock:
     +        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
    -+            # Initiate a handshake, which should result in the above endpoints
    -+            # being called.
     +            initial = start_oauth_handshake(conn)
     +
     +            # Validate and accept 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)
     +
     +
    @@ src/test/python/client/test_oauth.py (new)
     +    attempts = 0
     +    last_retry = None
     +    retry_lock = threading.Lock()
    ++    token_sent = threading.Event()
     +
     +    def token_endpoint(headers, params):
     +        now = time.monotonic()
    @@ src/test/python/client/test_oauth.py (new)
     +
     +                return 400, {"error": error_code}
     +
    -+        # Successfully finish the request by sending the access bearer token.
    ++        # Successfully finish the request by sending the access bearer token,
    ++        # and signal the main thread to continue.
     +        resp = {
     +            "access_token": access_token,
     +            "token_type": "bearer",
     +        }
    ++        token_sent.set()
     +
     +        return 200, resp
     +
    @@ src/test/python/client/test_oauth.py (new)
     +        "token_endpoint", "POST", "/token", token_endpoint
     +    )
     +
    ++    # First connection is a discovery request, which should result in the above
    ++    # endpoints being called.
    ++    with sock:
    ++        handle_discovery_connection(sock, openid_provider.discovery_uri)
    ++
    ++    # At this point the client is talking to the authorization server. Wait for
    ++    # that to succeed so we don't run into the accept() timeout.
    ++    token_sent.wait()
    ++
    ++    # Client should reconnect and send the token.
    ++    sock, _ = accept()
     +    with sock:
     +        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
    -+            # Initiate a handshake, which should result in the above endpoints
    -+            # being called.
     +            initial = start_oauth_handshake(conn)
     +
     +            # Validate and accept 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)
     +
     +
    @@ src/test/python/client/test_oauth.py (new)
     +    auth_data_cb.impl = bearer_hook
     +
     +    # Now drive the server side.
    ++    if retries >= 0:
    ++        # First connection is a discovery request, which should result in the
    ++        # hook being invoked.
    ++        with sock:
    ++            handle_discovery_connection(sock, discovery_uri)
    ++
    ++        # Client should reconnect to send the token.
    ++        sock, _ = accept()
    ++
     +    with sock:
     +        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
     +            # Initiate a handshake, which should result in our custom callback
    @@ src/test/python/client/test_oauth.py (new)
     +            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)
     +
     +    # Check the data provided to the hook.
    @@ src/test/python/client/test_oauth.py (new)
     +        "token_endpoint", "POST", "/token", token_endpoint
     +    )
     +
    -+    expect_disconnected_handshake(sock)
    ++    with sock:
    ++        handle_discovery_connection(sock, openid_provider.discovery_uri)
     +
     +    # Now make sure the client correctly failed.
     +    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
    @@ src/test/python/client/test_oauth.py (new)
     +        "token_endpoint", "POST", "/token", token_endpoint
     +    )
     +
    -+    expect_disconnected_handshake(sock)
    ++    with sock:
    ++        handle_discovery_connection(sock, openid_provider.discovery_uri)
     +
     +    # Now make sure the client correctly failed.
     +    if bad_value is Missing:
    @@ src/test/python/client/test_oauth.py (new)
     +        "token_endpoint", "POST", "/token", token_endpoint
     +    )
     +
    -+    expect_disconnected_handshake(sock)
    ++    with sock:
    ++        handle_discovery_connection(sock, openid_provider.discovery_uri)
     +
     +    # Now make sure the client correctly failed.
     +    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
    @@ src/test/python/client/test_oauth.py (new)
     +        "token_endpoint", "POST", "/token", token_endpoint
     +    )
     +
    -+    expect_disconnected_handshake(sock)
    ++    with sock:
    ++        handle_discovery_connection(sock, openid_provider.discovery_uri)
     +
     +    # Now make sure the client correctly failed.
     +    error_pattern = "failed to parse access token response: "
    @@ src/test/python/client/test_oauth.py (new)
     +        fail_resp["scope"] = scope
     +
     +    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 first SASL exchange.
    -+            fail_oauth_handshake(conn, fail_resp)
    ++        handle_discovery_connection(sock, response=fail_resp)
     +
     +    # The client will connect to us a second time, using the parameters we sent
     +    # it.
    @@ src/test/python/client/test_oauth.py (new)
     +            assert auth == f"Bearer {access_token}".encode("ascii")
     +
     +            if success:
    -+                pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal)
     +                finish_handshake(conn)
     +
     +            else:
    @@ src/test/python/client/test_oauth.py (new)
     +        failing_discovery_handler,
     +    )
     +
    -+    expect_disconnected_handshake(sock)
    ++    with sock:
    ++        handle_discovery_connection(sock, openid_provider.discovery_uri)
     +
     +    with pytest.raises(psycopg2.OperationalError, match=expected_error):
     +        client.check_completed()
    @@ src/test/python/client/test_oauth.py (new)
     +            dict(
     +                fields=[b"SFATAL", b"C28000", b"Mexpected error message", b""],
     +            ),
    -+            "expected error message",
    ++            "server rejected OAuth bearer token: invalid_request",
     +            id="standard server error: invalid_request",
     +        ),
     +        pytest.param(
    @@ src/test/python/client/test_oauth.py (new)
     +            id="standard server error: invalid_token without discovery URI",
     +        ),
     +        pytest.param(
    -+            {"status": "invalid_request"},
    ++            {"status": "invalid_token", "openid-configuration": ""},
     +            pq3.types.AuthnRequest,
     +            dict(type=pq3.authn.SASLContinue, body=b""),
     +            "server sent additional OAuth data",
     +            id="broken server: additional challenge after error",
     +        ),
     +        pytest.param(
    -+            {"status": "invalid_request"},
    ++            {"status": "invalid_token", "openid-configuration": ""},
     +            pq3.types.AuthnRequest,
     +            dict(type=pq3.authn.SASLFinal),
     +            "server sent additional OAuth data",
     +            id="broken server: SASL success after error",
     +        ),
     +        pytest.param(
    -+            {"status": "invalid_request"},
    ++            {"status": "invalid_token", "openid-configuration": ""},
     +            pq3.types.AuthnRequest,
     +            dict(type=pq3.authn.SASL, body=[b"OAUTHBEARER", b""]),
     +            "duplicate SASL authentication request",
    @@ src/test/python/client/test_oauth.py (new)
     +        ),
     +    ],
     +)
    -+def test_oauth_server_error(accept, sasl_err, resp_type, resp_payload, expected_error):
    ++def test_oauth_server_error(
    ++    accept, auth_data_cb, sasl_err, resp_type, resp_payload, expected_error
    ++):
    ++    wkuri = f"https://256.256.256.256/.well-known/openid-configuration"
     +    sock, client = accept(
    -+        oauth_issuer="https://example.com",
    ++        oauth_issuer=wkuri,
     +        oauth_client_id="some-id",
     +    )
     +
    ++    def bearer_hook(typ, pgconn, request):
    ++        """
    ++        Implementation of the PQAuthDataHook, which returns a token directly so
    ++        we don't need an openid_provider instance.
    ++        """
    ++        assert typ == PQAUTHDATA_OAUTH_BEARER_TOKEN
    ++        request.token = secrets.token_urlsafe().encode()
    ++        return 1
    ++
    ++    auth_data_cb.impl = bearer_hook
    ++
     +    with sock:
     +        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
     +            start_oauth_handshake(conn)
     +
     +            # Ignore the client data. Return an error "challenge".
    ++            if "openid-configuration" in sasl_err:
    ++                sasl_err["openid-configuration"] = wkuri
    ++
     +            resp = json.dumps(sasl_err)
     +            resp = resp.encode("utf-8")
     +
    @@ src/test/python/client/test_oauth.py (new)
     +            assert pkt.type == pq3.types.PasswordMessage
     +            assert pkt.payload == b"\x01"
     +
    -+            # Now fail the SASL exchange (in either a valid way, or an invalid
    -+            # one, depending on the test).
    ++            # Now fail the SASL exchange (in either a valid way, or an
    ++            # invalid one, depending on the test).
     +            pq3.send(conn, resp_type, **resp_payload)
     +
     +    with pytest.raises(psycopg2.OperationalError, match=expected_error):
    @@ src/test/python/client/test_oauth.py (new)
     +        "token_endpoint", "POST", "/token", token_endpoint
     +    )
     +
    -+    expect_disconnected_handshake(sock)
    ++    with sock:
    ++        handle_discovery_connection(sock, openid_provider.discovery_uri)
     +
     +    expected_error = "slow_down interval overflow"
     +    with pytest.raises(psycopg2.OperationalError, match=expected_error):
    @@ src/test/python/client/test_oauth.py (new)
     +    # No provider callbacks necessary; we should fail immediately.
     +
     +    with sock:
    -+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
    -+            initial = start_oauth_handshake(conn)
    -+
    -+            resp = {
    -+                "status": "invalid_token",
    -+                "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"),
    -+            )
    -+
    -+            # FIXME: the client disconnects at this point; it'd be nicer if
    -+            # it completed the exchange.
    ++        handle_discovery_connection(sock, to_http(openid_provider.discovery_uri))
     +
     +    expected_error = r'OAuth discovery URI ".*" must use HTTPS'
     +    with pytest.raises(psycopg2.OperationalError, match=expected_error):
    ++        client.check_completed()
    ++
    ++
    ++@pytest.mark.parametrize("auth_type", [pq3.authn.OK, pq3.authn.SASLFinal])
    ++def test_discovery_incorrectly_permits_connection(accept, auth_type):
    ++    """
    ++    Incorrectly responds to a client's discovery request with AuthenticationOK
    ++    or AuthenticationSASLFinal. require_auth=oauth should catch the former, and
    ++    the mechanism itself should catch the latter.
    ++    """
    ++    issuer = "https://256.256.256.256"
    ++    sock, client = accept(
    ++        oauth_issuer=issuer,
    ++        oauth_client_id=secrets.token_hex(),
    ++        require_auth="oauth",
    ++    )
    ++
    ++    with sock:
    ++        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
    ++            initial = start_oauth_handshake(conn)
    ++
    ++            auth = get_auth_value(initial)
    ++            assert auth == b""
    ++
    ++            # Incorrectly log the client in. It should immediately disconnect.
    ++            pq3.send(conn, pq3.types.AuthnRequest, type=auth_type)
    ++            assert not conn.read(1), "client sent unexpected data"
    ++
    ++    if auth_type == pq3.authn.OK:
    ++        expected_error = "server did not complete authentication"
    ++    else:
    ++        expected_error = "server sent unexpected additional OAuth data"
    ++
    ++    with pytest.raises(psycopg2.OperationalError, match=expected_error):
    ++        client.check_completed()
    ++
    ++
    ++def test_no_discovery_url_provided(accept):
    ++    """
    ++    Tests what happens when the client doesn't know who to contact and the
    ++    server doesn't tell it.
    ++    """
    ++    issuer = "https://256.256.256.256"
    ++    sock, client = accept(
    ++        oauth_issuer=issuer,
    ++        oauth_client_id=secrets.token_hex(),
    ++    )
    ++
    ++    with sock:
    ++        handle_discovery_connection(sock, discovery=None)
    ++
    ++    expected_error = "no discovery metadata was provided"
    ++    with pytest.raises(psycopg2.OperationalError, match=expected_error):
    ++        client.check_completed()
    ++
    ++
    ++@pytest.mark.parametrize("change_between_connections", [False, True])
    ++def test_discovery_url_changes(accept, openid_provider, change_between_connections):
    ++    """
    ++    Ensures that the client complains if the server agrees on the issuer, but
    ++    disagrees on the discovery URL to be used.
    ++    """
    ++
    ++    # Set up our provider callbacks.
    ++    # NOTE that these callbacks will be called on a background thread. Don't do
    ++    # any unprotected state mutation here.
    ++
    ++    def authorization_endpoint(headers, params):
    ++        resp = {
    ++            "device_code": "DEV",
    ++            "user_code": "USER",
    ++            "interval": 0,
    ++            "verification_uri": "https://example.org",
    ++            "expires_in": 5,
    ++        }
    ++
    ++        return 200, resp
    ++
    ++    openid_provider.register_endpoint(
    ++        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
    ++    )
    ++
    ++    def token_endpoint(headers, params):
    ++        resp = {
    ++            "access_token": secrets.token_urlsafe(),
    ++            "token_type": "bearer",
    ++        }
    ++
    ++        return 200, resp
    ++
    ++    openid_provider.register_endpoint(
    ++        "token_endpoint", "POST", "/token", token_endpoint
    ++    )
    ++
    ++    # Have the client connect.
    ++    sock, client = accept(
    ++        oauth_issuer=openid_provider.discovery_uri,
    ++        oauth_client_id="some-id",
    ++    )
    ++
    ++    other_wkuri = f"{openid_provider.issuer}/.well-known/oauth-authorization-server"
    ++
    ++    if not change_between_connections:
    ++        # Immediately respond with the wrong URL.
    ++        with sock:
    ++            handle_discovery_connection(sock, other_wkuri)
    ++
    ++    else:
    ++        # First connection; use the right URL to begin with.
    ++        with sock:
    ++            handle_discovery_connection(sock, openid_provider.discovery_uri)
    ++
    ++        # Second connection. Reject the token and switch the URL.
    ++        sock, _ = accept()
    ++        with sock:
    ++            with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
    ++                initial = start_oauth_handshake(conn)
    ++                get_auth_value(initial)
    ++
    ++                # Ignore the token; fail with a different discovery URL.
    ++                resp = {
    ++                    "status": "invalid_token",
    ++                    "openid-configuration": other_wkuri,
    ++                }
    ++                fail_oauth_handshake(conn, resp)
    ++
    ++    expected_error = rf"server's discovery document has moved to {other_wkuri} \(previous location was {openid_provider.discovery_uri}\)"
    ++    with pytest.raises(psycopg2.OperationalError, match=expected_error):
     +        client.check_completed()
     
      ## src/test/python/conftest.py (new) ##