[oauth] Stabilize the libpq-oauth ABI (and allow alternative implementations?)

Started by Jacob Championabout 1 month ago3 messages
#1Jacob Champion
jacob.champion@enterprisedb.com
7 attachment(s)

Hi everybody,

We introduced the libpq-oauth module late in the cycle for PG18, and
to put it mildly, its interface isn't great. The original
implementation depended on libpq internals, and we had to make sure
that we didn't start crashing during major or minor version upgrades.
So there were a bunch of compromises made to keep things safe,
including the restriction that the module name has to change every
major release.

Separately, but closely related: PG18's OAuth support allows you to
customize the client flow via a libpq hook function. Third-party
applications can make use of that, but our own utilities can still
only use the builtin device flow. That's annoying.

I've been working to replace the internal ABI with a stable one,
hopefully to solve both problems at the same time. A dlopen() is a
pretty clear seam for other people to use to modify and extend.
Unfortunately my first attempt (not pictured) ended up in a rabbit
hole, because I tried to tackle the third-party use case first. My
second attempt, attached, focuses on the ABI stabilization instead,
which I think is more likely to succeed.

(This took enough thinking that I'm really glad we didn't try this for
PG18. Thanks for letting me take on some technical debt for a bit.)

= Design =

Here's the train of thought behind the core changes (which are in patch 0004):

The builtin-flow code in fe-auth-oauth.c is similar to the custom-flow
code, but it's just ever-so-slightly different. I'd like to unify the
two, so I want libpq-oauth to make use of the public
PGoauthBearerRequest API, and that means that almost all of the
injections made in the PG18 ABI need to be replaced.

Most of those injections are simply subsumed by the hook API
(hooray!). A couple of others can be replaced by PQconninfo(). Four
are left over:
- pgthreadlock_t
- libpq_gettext
- conn->errorMessage
- conn->oauth_issuer_id

I think we should keep injecting libpq_gettext; no third-party
implementations would be able to use that. And application hooks are
presumably capable of figuring out top-level locking already, since
the application has to have called PQregisterThreadLock() if it needed
to coordinate with libpq.

That leaves error messages and the issuer identifier. I think both
would be useful for hooks to have, too, so I'd like to add them to
PQAUTHDATA_OAUTH_BEARER_TOKEN.

= PQAUTHDATA_OAUTH_BEARER_TOKEN, version 2 =

My original plan for authdata extensions was to add new members to the
end of the structs that libpq passes into the hook. Applications would
gate on a feature macro during compilation to see whether they could
use the new members. That should work fine for an application hook;
you're not allowed to downgrade libpq past the version that your
applications are compiled against, lest you lose symbols (or other
feature-flag functionality) you're relying on.

Plugins, unfortunately, can't rely on the feature macro. As we found
out during the libpq-oauth split [1]/messages/by-id/aAkJnDQq3mOUvmQV@msg.df7cb.de, we have to handle a long-running
application with an old libpq that loads an upgraded libpq-oauth, even
if the OS package dependencies suggest otherwise. (A plugin
architecture flips the direction of the runtime dependency arrow.)

There are a couple ways we could handle this. For this draft, I've
implemented what I think is a middle ground between verbosity and
type-safety: introduce a new V2 struct that "inherits" the V1 struct
and can be down-cast in the callbacks, kinda similar to our Node
hierarchy. We could go even more verbose, and duplicate the entire
PGoauthBearerToken struct -- matching the callback parameter types for
maximum safety -- but I'm not convinced that this would be a good use
of maintenance effort. The ability to cast between the two means we
can share v1 and v2 implementations in our tests.

We could also just add the new members at the end, say that you're
only allowed to use them if the V2 hook type is in use, and scribble
on them in V1 hooks to try to get misbehaving implementations to crash
outright. This arguably has the same amount of type-safety as the
downcast, and the resulting code looks nicer. (The libcurl API we use
does something similar with curl_version_info().) But it is definitely
more "magic".

Also of note: this adds a PQExpBuffer to libpq-fe.h. Technically, that
type is "internal". But... is it really, though? It doesn't seem
possible for us to make incompatible changes there without crashing
earlier psqls, in which case I would like to make use of it too. Maybe
this deserves its own minithread.

Okay, on to the full patchset.

= Roadmap: Prep =

The first few patches are bugfixes I intend to backpatch to 18.

- 0001: I stomped on the SOCKTYPE name in libpq-fe.h, but that's not
in our namespace and it's conceivable that it might collide with
someone else. (It collided with my own test code during my work on
this.)
- 0002 fixes a copy-paste bug in meson.build, which luckily hadn't
caused problems yet.
- 0003 ports Tom's debug2 fix for Test::Cluster::connect_fails() over
to 002_client.pl. (Currently, log checks in this test aren't made
after connection failures, but I don't really want to chase that down
later after I've forgotten about it.)

= Roadmap: Implementation =

Next three patches are the core implementation, which stabilizes the
ABI for libpq-oauth. I feel fairly confident that this, or something
close to it, could land in PG19.

- 0004 introduces the new PQAUTHDATA_OAUTH_BEARER_TOKEN_V2 API.

As described above.

- 0005 makes use of the new API in libpq-oauth.

This removes more code than it introduces, which is exciting.

Now we can rename libpq-oauth-19.so to just libpq-oauth.so, since we
no longer depend on anything that could change between major versions.
(We still need lock and locale support from libpq, as mentioned above,
so they continue to be decoupled via injection at init time.)

Some of the code in this patch overlaps with the translation fixes
thread [2]/messages/by-id/TY4PR01MB1690746DB91991D1E9A47F57E94CDA@TY4PR01MB16907.jpnprd01.prod.outlook.com, which I need to get to first. I'm hoping some additional
simplifications can be made after those localization bugs are fixed.

I think I'd also like to get the threadlock into the module API (but
not the hook API). A third-party flow might need to perform its own
one-time initialization, and if it relies on an old version of Curl
(or worse, Kerberos [3]/messages/by-id/aSSp03wmNMngi/Oe@ubby), it'll need to use the same lock that the
top-level application has registered for libpq. So I imagine I'll need
to break out an initialization function here. Alternatively, I could
introduce an API into libpq to retrieve the threadlock function in
use?

- 0006 removes a potential ABI-compatibility pitfall for future devs.

libpq-oauth needs to use the shared-library variant of libpgcommon,
but it can no longer assume that the encoding support exported by
libpq is compatible. So it must not accidentally link against those
functions (see [4]https://postgr.es/c/b6c7cfac8). I don't imagine anyone will try adding code that
does this in practice; we're pretty UTF8-centric in OAuth. But just to
be safe, define USE_PRIVATE_ENCODING_FUNCS so that anyone who tries
will fail the build.

= Roadmap: Plugins? =

So now we have a stable ABI, which technically means that any
enterprising developer who wants to play games with LD_LIBRARY_PATH
could implement their own version of an OAuth flow, and have our
utilities make use of it into the future.

That brings us to patch 0007, which experimentally promotes the stable
API to a public header, and introduces a really janky environment
variable so that people don't have to play games. It will be obvious
from the code that this is not well-baked yet.

I hope the ability to dlopen() a custom flow can make it for 19; I
just don't know that this envvar approach is any good. The ideal
situation, IMO, is for a flow to be selected in the connection string.
But we have to lock that down, similarly to how we protect
local_preload_libraries, to prevent horrible exploits. At which point
we'll have essentially designed a generic libpq plugin system. Not
necessarily a terrible thing, but I don't think I have time to take it
on for PG19.

WDYT?
--Jacob

[1]: /messages/by-id/aAkJnDQq3mOUvmQV@msg.df7cb.de
[2]: /messages/by-id/TY4PR01MB1690746DB91991D1E9A47F57E94CDA@TY4PR01MB16907.jpnprd01.prod.outlook.com
[3]: /messages/by-id/aSSp03wmNMngi/Oe@ubby
[4]: https://postgr.es/c/b6c7cfac8

Attachments:

0001-libpq-fe.h-Don-t-claim-SOCKTYPE-in-the-global-namesp.patchapplication/octet-stream; name=0001-libpq-fe.h-Don-t-claim-SOCKTYPE-in-the-global-namesp.patchDownload
From 3b27f54b4dcc115d694a655f538e6643318fabb8 Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Mon, 1 Dec 2025 10:50:42 -0800
Subject: [PATCH 1/7] libpq-fe.h: Don't claim SOCKTYPE in the global namespace

The definition of PGoauthBearerRequest uses a temporary SOCKTYPE macro
to hide the difference between Windows and Berkeley socket handles,
since we don't surface pgsocket in our public API. This macro doesn't
need to escape the header, because implementers will choose the correct
socket type based on their platform, so I #undef'd it immediately after
use.

I didn't namespace that helper, though, so if anyone else needs a
SOCKTYPE macro, libpq-fe.h will now unhelpfully get rid of it. This
doesn't seem too far-fetched, given its proximity to existing POSIX
macro names.

Add a PQ_ prefix to avoid collisions, and backpatch.

Backpatch-through: 18
---
 doc/src/sgml/libpq.sgml         | 2 +-
 src/interfaces/libpq/libpq-fe.h | 8 ++++----
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index 7ab679a765d..45eadc4de7e 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -10425,7 +10425,7 @@ typedef struct PGoauthBearerRequest
     /* Callback implementing a custom asynchronous OAuth flow. */
     PostgresPollingStatusType (*async) (PGconn *conn,
                                         struct PGoauthBearerRequest *request,
-                                        SOCKTYPE *altsock);
+                                        PQ_SOCKTYPE *altsock);
 
     /* Callback to clean up custom allocations. */
     void        (*cleanup) (PGconn *conn, struct PGoauthBearerRequest *request);
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index 0852584edae..29ee0c8a4fd 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -740,9 +740,9 @@ typedef struct _PGpromptOAuthDevice
 
 /* for PGoauthBearerRequest.async() */
 #ifdef _WIN32
-#define SOCKTYPE uintptr_t		/* avoids depending on winsock2.h for SOCKET */
+#define PQ_SOCKTYPE uintptr_t	/* avoids depending on winsock2.h for SOCKET */
 #else
-#define SOCKTYPE int
+#define PQ_SOCKTYPE int
 #endif
 
 typedef struct PGoauthBearerRequest
@@ -771,7 +771,7 @@ typedef struct PGoauthBearerRequest
 	 */
 	PostgresPollingStatusType (*async) (PGconn *conn,
 										struct PGoauthBearerRequest *request,
-										SOCKTYPE * altsock);
+										PQ_SOCKTYPE * altsock);
 
 	/*
 	 * Callback to clean up custom allocations. A hook implementation may use
@@ -798,7 +798,7 @@ typedef struct PGoauthBearerRequest
 	void	   *user;
 } PGoauthBearerRequest;
 
-#undef SOCKTYPE
+#undef PQ_SOCKTYPE
 
 extern char *PQencryptPassword(const char *passwd, const char *user);
 extern char *PQencryptPasswordConn(PGconn *conn, const char *passwd, const char *user, const char *algorithm);
-- 
2.34.1

0002-libpq-oauth-use-correct-c_args-in-meson.build.patchapplication/octet-stream; name=0002-libpq-oauth-use-correct-c_args-in-meson.build.patchDownload
From 3b5690a49d7dc36524bd3e69fefb15fd8b005e61 Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Wed, 3 Dec 2025 08:55:51 -0800
Subject: [PATCH 2/7] libpq-oauth: use correct c_args in meson.build

Copy-paste bug from b0635bfda: libpq-oauth.so was being built with
libpq_so_c_args, rather than libpq_oauth_so_c_args. (At the moment, the
two lists are identical, but that won't be true forever.)

Backpatch-through: 18
---
 src/interfaces/libpq-oauth/meson.build | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/interfaces/libpq-oauth/meson.build b/src/interfaces/libpq-oauth/meson.build
index 505e1671b86..881e3f24f10 100644
--- a/src/interfaces/libpq-oauth/meson.build
+++ b/src/interfaces/libpq-oauth/meson.build
@@ -40,7 +40,7 @@ libpq_oauth_name = 'libpq-oauth-@0@'.format(pg_version_major)
 libpq_oauth_so = shared_module(libpq_oauth_name,
   libpq_oauth_sources + libpq_oauth_so_sources,
   include_directories: [libpq_oauth_inc, postgres_inc],
-  c_args: libpq_so_c_args,
+  c_args: libpq_oauth_so_c_args,
   c_pch: pch_postgres_fe_h,
   dependencies: [frontend_shlib_code, libpq, libpq_oauth_deps],
   link_depends: export_file,
-- 
2.34.1

0003-oauth_validator-Avoid-races-in-log_check.patchapplication/octet-stream; name=0003-oauth_validator-Avoid-races-in-log_check.patchDownload
From 4933e9b53fb56f40b7d37f6f805b4d4eb17791be Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Thu, 4 Dec 2025 16:34:09 -0800
Subject: [PATCH 3/7] oauth_validator: Avoid races in log_check()

Commit e0f373ee4 fixed up races in Cluster::connect_fails when using
log_like. t/002_client.pl didn't get the memo, though, because it
doesn't use Test::Cluster to perform its custom hook tests. Introduce
the fix, based on debug2 logging, to its use of log_check() as well, and
move the logic into the test() helper so that any additions don't need
to continually duplicate it.

Backpatch-through: 18
---
 .../modules/oauth_validator/t/002_client.pl   | 24 ++++++++++++++-----
 1 file changed, 18 insertions(+), 6 deletions(-)

diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
index aac0220d215..e6c91fc911c 100644
--- a/src/test/modules/oauth_validator/t/002_client.pl
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -29,6 +29,8 @@ $node->init;
 $node->append_conf('postgresql.conf', "log_connections = all\n");
 $node->append_conf('postgresql.conf',
 	"oauth_validator_libraries = 'validator'\n");
+# Needed to inspect postmaster log after connection failure:
+$node->append_conf('postgresql.conf', "log_min_messages = debug2");
 $node->start;
 
 $node->safe_psql('postgres', 'CREATE USER test;');
@@ -47,7 +49,7 @@ local all test oauth issuer="$issuer" scope="$scope"
 });
 $node->reload;
 
-my $log_start = $node->wait_for_log(qr/reloading configuration files/);
+$node->wait_for_log(qr/reloading configuration files/);
 
 $ENV{PGOAUTHDEBUG} = "UNSAFE";
 
@@ -73,6 +75,7 @@ sub test
 	my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr);
 	note "running '" . join("' '", @cmd) . "'";
 
+	my $log_start = -s $node->logfile;
 	my ($stdout, $stderr) = run_command(\@cmd);
 
 	if (defined($params{expected_stdout}))
@@ -88,6 +91,18 @@ sub test
 	{
 		is($stderr, "", "$test_name: no stderr");
 	}
+
+	if (defined($params{log_like}))
+	{
+		# See Cluster::connect_fails(). To avoid races, we have to wait for the
+		# postmaster to flush the log for the finished connection.
+		$node->wait_for_log(
+			qr/DEBUG:  (?:00000: )?forked new client backend, pid=(\d+) socket.*DEBUG:  (?:00000: )?client backend \(PID \1\) exited with exit code \d/s,
+			$log_start);
+
+		$node->log_check("$test_name: log matches",
+			$log_start, log_like => $params{log_like});
+	}
 }
 
 test(
@@ -97,11 +112,8 @@ test(
 		"--expected-uri", "$issuer/.well-known/openid-configuration",
 		"--expected-scope", $scope,
 	],
-	expected_stdout => qr/connection succeeded/);
-
-$node->log_check("validator receives correct token",
-	$log_start,
-	log_like => [ qr/oauth_validator: token="my-token", role="$user"/, ]);
+	expected_stdout => qr/connection succeeded/,
+	log_like => [qr/oauth_validator: token="my-token", role="$user"/]);
 
 if ($ENV{with_libcurl} ne 'yes')
 {
-- 
2.34.1

0004-libpq-Introduce-PQAUTHDATA_OAUTH_BEARER_TOKEN_V2.patchapplication/octet-stream; name=0004-libpq-Introduce-PQAUTHDATA_OAUTH_BEARER_TOKEN_V2.patchDownload
From b2cece52ba68a83b6ecdbda6c5c45d4aeb0b66ac Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Mon, 1 Dec 2025 15:07:26 -0800
Subject: [PATCH 4/7] libpq: Introduce PQAUTHDATA_OAUTH_BEARER_TOKEN_V2

For the libpq-oauth module to eventually make use of the
PGoauthBearerRequest API, it needs some additional functionality: the
derived Issuer ID for the authorization server needs to be provided by
libpq, and error messages need to be built without relying on PGconn
internals. These features seem useful for application hooks, too, so
that they don't each have to reinvent the wheel.

The original plan was for additions to PGoauthBearerRequest to be made
without a version bump to the PGauthData type, and that applications
would simply check a LIBPQ_HAS_* macro at compile time to decide whether
they could use the new features. That theoretically works for
applications linked against libpq, since it's not safe to downgrade
libpq from the version you've compiled against.

That strategy won't work for plugins, though, due to a complication
first noticed during the libpq-oauth module split: it's normal for a
plugin on disk to be *newer* than the libpq that's loading it, because
you might have upgraded your installation while an application was
running. (Put another way: a plugin architecture causes the compile-time
and run-time dependency arrows to point in opposite directions, so
plugins won't be able to rely on the LIBPQ_HAS_* macros to determine
what APIs are available to them.)

(TODO: Are there implications for our use of RTLD_NOW at dlopen() time?
Can this be improved?)

Instead, add a new struct which extends the "v1" PGoauthBearerRequest.
When an application hook receives PQAUTHDATA_OAUTH_BEARER_TOKEN_V2, it
may safely downcast the request pointer it receives in its callbacks to
make use of the new functionality. libpq will first try the new version,
then fall back to the old before giving up.

TODO: Error handling introduces a dependency on pqexpbuffer.h, which is
      technically considered internal... but we export the ABI for psql,
      so is it really internal at this point?
TODO: Could we just add to the end of PGoauthBearerRequest, and tell
      users not to use the additional fields if they have version 1?
---
 doc/src/sgml/libpq.sgml                       |  87 +++++++++++++++
 src/tools/pgindent/typedefs.list              |   1 +
 src/interfaces/libpq/libpq-fe.h               |  32 +++++-
 src/interfaces/libpq/fe-auth-oauth.c          | 104 +++++++++++++-----
 .../oauth_validator/oauth_hook_client.c       |  97 +++++++++++++++-
 .../modules/oauth_validator/t/002_client.pl   |  38 ++++++-
 6 files changed, 324 insertions(+), 35 deletions(-)

diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index 45eadc4de7e..baf8ef4d3d0 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -10350,6 +10350,7 @@ PQauthDataHook_type PQgetAuthDataHook(void);
         <indexterm><primary>PQAUTHDATA_PROMPT_OAUTH_DEVICE</primary></indexterm>
        </term>
        <listitem>
+        <para><emphasis>Available in PostgreSQL 18 and later.</emphasis></para>
         <para>
          Replaces the default user prompt during the builtin device
          authorization client flow. <replaceable>data</replaceable> points to
@@ -10402,6 +10403,7 @@ typedef struct _PGpromptOAuthDevice
         <indexterm><primary>PQAUTHDATA_OAUTH_BEARER_TOKEN</primary></indexterm>
        </term>
        <listitem>
+        <para><emphasis>Available in PostgreSQL 18 and later.</emphasis></para>
         <para>
          Adds a custom implementation of a flow, replacing the builtin flow if
          it is <link linkend="configure-option-with-libcurl">installed</link>.
@@ -10409,6 +10411,13 @@ typedef struct _PGpromptOAuthDevice
          user/issuer/scope combination, if one is available without blocking, or
          else set up an asynchronous callback to retrieve one.
         </para>
+        <note>
+         <para>
+          For <productname>PostgreSQL</productname> releases 19 and later,
+          applications should prefer
+          <link linkend="libpq-oauth-authdata-oauth-bearer-token-v2"><literal>PQAUTHDATA_OAUTH_BEARER_TOKEN_V2</literal></link>.
+         </para>
+        </note>
         <para>
          <replaceable>data</replaceable> points to an instance
          of <symbol>PGoauthBearerRequest</symbol>, which should be filled in
@@ -10500,6 +10509,84 @@ typedef struct PGoauthBearerRequest
         </para>
        </listitem>
       </varlistentry>
+
+      <varlistentry id="libpq-oauth-authdata-oauth-bearer-token-v2">
+       <term>
+        <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN_V2</symbol>
+        <indexterm>
+         <primary>PQAUTHDATA_OAUTH_BEARER_TOKEN_V2</primary>
+         <secondary>PQAUTHDATA_OAUTH_BEARER_TOKEN</secondary>
+        </indexterm>
+       </term>
+       <listitem>
+        <para><emphasis>Available in PostgreSQL 19 and later.</emphasis></para>
+        <para>
+         Provides all the functionality of
+         <link linkend="libpq-oauth-authdata-oauth-bearer-token"><literal>PQAUTHDATA_OAUTH_BEARER_TOKEN</literal></link>,
+         and adds the ability to set custom error messages and inspect the
+         OAuth issuer ID that the client expects to use.
+        </para>
+        <para>
+         <replaceable>data</replaceable> points to an instance
+         of <symbol>PGoauthBearerRequestV2</symbol>, which should be filled in
+         by the implementation:
+<synopsis>
+typedef struct
+{
+    PGoauthBearerRequest v1;    /* see the PGoauthBearerRequest struct, above */
+
+    /* Hook inputs (constant across all calls) */
+    const char *issuer;            /* the issuer identifier (RFC 9207) in use */
+
+    /* Hook outputs */
+
+    /* An initialized, empty buffer for reporting errors when the flow fails. */
+    struct PQExpBufferData *error;
+} PGoauthBearerRequestV2;
+</synopsis>
+        </para>
+        <para>
+         When a <literal>PQAUTHDATA_OAUTH_BEARER_TOKEN_V2</literal> hook is in
+         use, <application>libpq</application> additionally guarantees that the
+         <literal>request</literal> pointer that is provided to the
+         <replaceable>v1.async</replaceable> and <replaceable>v1.cleanup</replaceable>
+         callbacks may be safely cast to <literal>(PGoauthBearerRequestV2&nbsp;*)</literal>.
+         Implementations must otherwise follow the v1 API, as described above,
+         using the <replaceable>v1</replaceable> struct member.
+        </para>
+        <warning>
+         <para>
+          Casting to <literal>(PGoauthBearerRequestV2&nbsp;*)</literal> is
+          <emphasis>only</emphasis> safe when the hook type is
+          <literal>PQAUTHDATA_OAUTH_BEARER_TOKEN_V2</literal>. Applications may
+          crash or misbehave if a hook attempts to use v2 features with the v1
+          hook type.
+         </para>
+        </warning>
+        <para>
+         In addition to the functionality of the version 1 API, the v2 struct
+         provides an additional input and output for the hook:
+        </para>
+        <para>
+         <replaceable>issuer</replaceable> contains the issuer identifier, as
+         defined in <ulink url="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</ulink>,
+         that is in use for the current connection. This identifier is
+         determined by <xref linkend="libpq-connect-oauth-issuer"/>.
+         To avoid mix-up attacks, custom flows should ensure that any discovery
+         metadata provided by the authorization server matches this issuer ID.
+        </para>
+        <para>
+         <replaceable>error</replaceable> contains an empty
+         <literal>PQExpBuffer</literal> that can be used to construct a custom
+         error message when a flow fails. The message will be included as part
+         of <link linkend="libpq-PQerrorMessage"><literal>PQerrorMessage()</literal></link>.
+         Hooks must not free or reassign this buffer; it is managed by
+         <application>libpq</application>. The API for manipulating the error
+         buffer is provided in <literal>"internal/pqexpbuffer.h"</literal>.
+        </para>
+       </listitem>
+      </varlistentry>
+
      </variablelist>
     </para>
    </sect3>
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index c1ad80a418d..be8fe0ac22b 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1906,6 +1906,7 @@ PGdataValue
 PGlobjfuncs
 PGnotify
 PGoauthBearerRequest
+PGoauthBearerRequestV2
 PGpipelineStatus
 PGpromptOAuthDevice
 PGresAttDesc
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index 29ee0c8a4fd..8f839785fa1 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -63,6 +63,10 @@ extern "C"
 /* Indicates presence of the PQAUTHDATA_PROMPT_OAUTH_DEVICE authdata hook */
 #define LIBPQ_HAS_PROMPT_OAUTH_DEVICE 1
 
+/* Features added in PostgreSQL v19: */
+/* Indicates presence of the PQAUTHDATA_OAUTH_BEARER_TOKEN_V2 authdata hook */
+#define LIBPQ_HAS_OAUTH_BEARER_TOKEN_V2 1
+
 /*
  * Option flags for PQcopyResult
  */
@@ -193,7 +197,9 @@ typedef enum
 {
 	PQAUTHDATA_PROMPT_OAUTH_DEVICE, /* user must visit a device-authorization
 									 * URL */
-	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token */
+	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token
+									 * (prefer v2, below, instead) */
+	PQAUTHDATA_OAUTH_BEARER_TOKEN_V2,	/* newest API for OAuth Bearer tokens */
 } PGauthData;
 
 /* PGconn encapsulates a connection to the backend.
@@ -729,6 +735,7 @@ extern int	PQenv2encoding(void);
 
 /* === in fe-auth.c === */
 
+/* Authdata for PQAUTHDATA_PROMPT_OAUTH_DEVICE */
 typedef struct _PGpromptOAuthDevice
 {
 	const char *verification_uri;	/* verification URI to visit */
@@ -745,6 +752,7 @@ typedef struct _PGpromptOAuthDevice
 #define PQ_SOCKTYPE int
 #endif
 
+/* Authdata for PQAUTHDATA_OAUTH_BEARER_TOKEN */
 typedef struct PGoauthBearerRequest
 {
 	/* Hook inputs (constant across all calls) */
@@ -800,6 +808,28 @@ typedef struct PGoauthBearerRequest
 
 #undef PQ_SOCKTYPE
 
+struct PQExpBufferData;			/* see pqexpbuffer.h */
+
+/* Authdata for PQAUTHDATA_OAUTH_BEARER_TOKEN_V2 */
+typedef struct
+{
+	PGoauthBearerRequest v1;	/* see the PGoauthBearerRequest struct, above */
+
+	/* Hook inputs (constant across all calls) */
+	const char *issuer;			/* the issuer identifier (RFC 9207) in use, as
+								 * derived from the connection's oauth_issuer */
+
+	/* Hook outputs */
+
+	/*
+	 * An initialized, empty buffer for reporting errors when the flow fails.
+	 * Implementations may use the API in pqexpbuffer.h to build a custom
+	 * error description. It will be included in the connection's
+	 * PQerrorMessage() output.
+	 */
+	struct PQExpBufferData *error;
+} PGoauthBearerRequestV2;
+
 extern char *PQencryptPassword(const char *passwd, const char *user);
 extern char *PQencryptPasswordConn(PGconn *conn, const char *passwd, const char *user, const char *algorithm);
 extern PGresult *PQchangePassword(PGconn *conn, const char *user, const char *passwd);
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
index d146c5f567c..265ec43035a 100644
--- a/src/interfaces/libpq/fe-auth-oauth.c
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -668,6 +668,32 @@ cleanup:
 	return success;
 }
 
+/*
+ * Helper for handling user flow failures. If the implementation put anything
+ * into request->error, it's added to conn->errorMessage here.
+ */
+static void
+report_user_flow_error(PGconn *conn, const PGoauthBearerRequestV2 *request)
+{
+	const char *errmsg = NULL;
+
+	if (PQExpBufferBroken(request->error))
+		errmsg = libpq_gettext("out of memory");
+	else if (request->error->len)
+		errmsg = request->error->data;
+
+	appendPQExpBufferStr(&conn->errorMessage,
+						 libpq_gettext("user-defined OAuth flow failed"));
+
+	if (errmsg)
+	{
+		appendPQExpBufferStr(&conn->errorMessage, ": ");
+		appendPQExpBufferStr(&conn->errorMessage, errmsg);
+	}
+
+	appendPQExpBufferChar(&conn->errorMessage, '\n');
+}
+
 /*
  * Callback implementation of conn->async_auth() for a user-defined OAuth flow.
  * Delegates the retrieval of the token to the application's async callback.
@@ -680,20 +706,23 @@ static PostgresPollingStatusType
 run_user_oauth_flow(PGconn *conn)
 {
 	fe_oauth_state *state = conn->sasl_state;
-	PGoauthBearerRequest *request = state->async_ctx;
+	PGoauthBearerRequestV2 *request = state->async_ctx;
 	PostgresPollingStatusType status;
 
-	if (!request->async)
+	if (!request->v1.async)
 	{
 		libpq_append_conn_error(conn,
 								"user-defined OAuth flow provided neither a token nor an async callback");
 		return PGRES_POLLING_FAILED;
 	}
 
-	status = request->async(conn, request, &conn->altsock);
+	status = request->v1.async(conn,
+							   (PGoauthBearerRequest *) request,
+							   &conn->altsock);
+
 	if (status == PGRES_POLLING_FAILED)
 	{
-		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		report_user_flow_error(conn, request);
 		return status;
 	}
 	else if (status == PGRES_POLLING_OK)
@@ -703,14 +732,14 @@ run_user_oauth_flow(PGconn *conn)
 		 * onto the original string, since it may not be safe for us to free()
 		 * it.)
 		 */
-		if (!request->token)
+		if (!request->v1.token)
 		{
 			libpq_append_conn_error(conn,
 									"user-defined OAuth flow did not provide a token");
 			return PGRES_POLLING_FAILED;
 		}
 
-		conn->oauth_token = strdup(request->token);
+		conn->oauth_token = strdup(request->v1.token);
 		if (!conn->oauth_token)
 		{
 			libpq_append_conn_error(conn, "out of memory");
@@ -732,21 +761,23 @@ run_user_oauth_flow(PGconn *conn)
 }
 
 /*
- * Cleanup callback for the async user flow. Delegates most of its job to the
- * user-provided cleanup implementation, then disconnects the altsock.
+ * Cleanup callback for the async user flow. Delegates most of its job to
+ * PGoauthBearerRequestV2.cleanup(), then disconnects the altsock and frees the
+ * request itself.
  */
 static void
 cleanup_user_oauth_flow(PGconn *conn)
 {
 	fe_oauth_state *state = conn->sasl_state;
-	PGoauthBearerRequest *request = state->async_ctx;
+	PGoauthBearerRequestV2 *request = state->async_ctx;
 
 	Assert(request);
 
-	if (request->cleanup)
-		request->cleanup(conn, request);
+	if (request->v1.cleanup)
+		request->v1.cleanup(conn, (PGoauthBearerRequest *) request);
 	conn->altsock = PGINVALID_SOCKET;
 
+	destroyPQExpBuffer(request->error);
 	free(request);
 	state->async_ctx = NULL;
 }
@@ -968,8 +999,8 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state)
  * token for presentation to the server.
  *
  * If the application has registered a custom flow handler using
- * PQAUTHDATA_OAUTH_BEARER_TOKEN, it may either return a token immediately (e.g.
- * if it has one cached for immediate use), or set up for a series of
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN[_V2], it may either return a token immediately
+ * (e.g. if it has one cached for immediate use), or set up for a series of
  * asynchronous callbacks which will be managed by run_user_oauth_flow().
  *
  * If the default handler is used instead, a Device Authorization flow is used
@@ -983,27 +1014,44 @@ static bool
 setup_token_request(PGconn *conn, fe_oauth_state *state)
 {
 	int			res;
-	PGoauthBearerRequest request = {
-		.openid_configuration = conn->oauth_discovery_uri,
-		.scope = conn->oauth_scope,
+	PGoauthBearerRequestV2 request = {
+		.v1 = {
+			.openid_configuration = conn->oauth_discovery_uri,
+			.scope = conn->oauth_scope,
+		},
+		.issuer = conn->oauth_issuer_id,
+		.error = createPQExpBuffer(),
 	};
 
-	Assert(request.openid_configuration);
+	Assert(request.v1.openid_configuration);
+	Assert(request.issuer);
+
+	if (PQExpBufferBroken(request.error))
+	{
+		libpq_append_conn_error(conn, "out of memory");
+		return false;
+	}
+
+	/*
+	 * The client may have overridden the OAuth flow. Try the v2 hook first,
+	 * then fall back to the v1 implementation.
+	 */
+	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN_V2, conn, &request);
+	if (res == 0)
+		res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
 
-	/* The client may have overridden the OAuth flow. */
-	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
 	if (res > 0)
 	{
-		PGoauthBearerRequest *request_copy;
+		PGoauthBearerRequestV2 *request_copy;
 
-		if (request.token)
+		if (request.v1.token)
 		{
 			/*
 			 * 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.)
 			 */
-			conn->oauth_token = strdup(request.token);
+			conn->oauth_token = strdup(request.v1.token);
 			if (!conn->oauth_token)
 			{
 				libpq_append_conn_error(conn, "out of memory");
@@ -1011,8 +1059,9 @@ setup_token_request(PGconn *conn, fe_oauth_state *state)
 			}
 
 			/* short-circuit */
-			if (request.cleanup)
-				request.cleanup(conn, &request);
+			if (request.v1.cleanup)
+				request.v1.cleanup(conn, (PGoauthBearerRequest *) &request);
+			destroyPQExpBuffer(request.error);
 			return true;
 		}
 
@@ -1031,7 +1080,7 @@ setup_token_request(PGconn *conn, fe_oauth_state *state)
 	}
 	else if (res < 0)
 	{
-		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		report_user_flow_error(conn, &request);
 		goto fail;
 	}
 	else if (!use_builtin_flow(conn, state))
@@ -1043,8 +1092,9 @@ setup_token_request(PGconn *conn, fe_oauth_state *state)
 	return true;
 
 fail:
-	if (request.cleanup)
-		request.cleanup(conn, &request);
+	if (request.v1.cleanup)
+		request.v1.cleanup(conn, (PGoauthBearerRequest *) &request);
+	destroyPQExpBuffer(request.error);
 	return false;
 }
 
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
index 15d0cf938a8..b1a3b014079 100644
--- a/src/test/modules/oauth_validator/oauth_hook_client.c
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -20,6 +20,7 @@
 
 #include "getopt_long.h"
 #include "libpq-fe.h"
+#include "pqexpbuffer.h"
 
 static int	handle_auth_data(PGauthData type, PGconn *conn, void *data);
 static PostgresPollingStatusType async_cb(PGconn *conn,
@@ -36,13 +37,16 @@ usage(char *argv[])
 
 	printf("recognized flags:\n");
 	printf("  -h, --help              show this message\n");
+	printf("  -v VERSION              select the hook API version (default 2)\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("  --expected-issuer ISS   fail if received issuer does not match ISS (v2 only)\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\n");
 	printf("  --hang-forever          don't ever return a token (combine with connect_timeout)\n");
 	printf("  --token TOKEN           use the provided TOKEN value\n");
+	printf("  --error ERRMSG          fail instead, with the given ERRMSG (v2 only)\n");
 	printf("  --stress-async          busy-loop on PQconnectPoll rather than polling\n");
 }
 
@@ -51,9 +55,12 @@ static bool no_hook = false;
 static bool hang_forever = false;
 static bool stress_async = false;
 static const char *expected_uri = NULL;
+static const char *expected_issuer = NULL;
 static const char *expected_scope = NULL;
 static const char *misbehave_mode = NULL;
 static char *token = NULL;
+static char *errmsg = NULL;
+static int	hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN_V2;
 
 int
 main(int argc, char *argv[])
@@ -68,6 +75,8 @@ main(int argc, char *argv[])
 		{"hang-forever", no_argument, NULL, 1004},
 		{"misbehave", required_argument, NULL, 1005},
 		{"stress-async", no_argument, NULL, 1006},
+		{"expected-issuer", required_argument, NULL, 1007},
+		{"error", required_argument, NULL, 1008},
 		{0}
 	};
 
@@ -75,7 +84,7 @@ main(int argc, char *argv[])
 	PGconn	   *conn;
 	int			c;
 
-	while ((c = getopt_long(argc, argv, "h", long_options, NULL)) != -1)
+	while ((c = getopt_long(argc, argv, "hv:", long_options, NULL)) != -1)
 	{
 		switch (c)
 		{
@@ -83,6 +92,18 @@ main(int argc, char *argv[])
 				usage(argv);
 				return 0;
 
+			case 'v':
+				if (strcmp(optarg, "1") == 0)
+					hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN;
+				else if (strcmp(optarg, "2") == 0)
+					hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN_V2;
+				else
+				{
+					usage(argv);
+					return 1;
+				}
+				break;
+
 			case 1000:			/* --expected-scope */
 				expected_scope = optarg;
 				break;
@@ -111,6 +132,14 @@ main(int argc, char *argv[])
 				stress_async = true;
 				break;
 
+			case 1007:			/* --expected-issuer */
+				expected_issuer = optarg;
+				break;
+
+			case 1008:			/* --error */
+				errmsg = optarg;
+				break;
+
 			default:
 				usage(argv);
 				return 1;
@@ -167,16 +196,24 @@ main(int argc, char *argv[])
 
 /*
  * PQauthDataHook implementation. Replaces the default client flow by handling
- * PQAUTHDATA_OAUTH_BEARER_TOKEN.
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN[_V2].
  */
 static int
 handle_auth_data(PGauthData type, PGconn *conn, void *data)
 {
-	PGoauthBearerRequest *req = data;
+	PGoauthBearerRequest *req;
+	PGoauthBearerRequestV2 *req2 = NULL;
+
+	Assert(hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN ||
+		   hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN_V2);
 
-	if (no_hook || (type != PQAUTHDATA_OAUTH_BEARER_TOKEN))
+	if (no_hook || type != hook_version)
 		return 0;
 
+	req = data;
+	if (type == PQAUTHDATA_OAUTH_BEARER_TOKEN_V2)
+		req2 = data;
+
 	if (hang_forever)
 	{
 		/* Start asynchronous processing. */
@@ -221,6 +258,44 @@ handle_auth_data(PGauthData type, PGconn *conn, void *data)
 		}
 	}
 
+	if (expected_issuer)
+	{
+		if (!req2)
+		{
+			fprintf(stderr, "--expected-issuer cannot be combined with -v1\n");
+			return -1;
+		}
+
+		if (!req2->issuer)
+		{
+			fprintf(stderr, "expected issuer \"%s\", got NULL\n", expected_issuer);
+			return -1;
+		}
+
+		if (strcmp(expected_issuer, req2->issuer) != 0)
+		{
+			fprintf(stderr, "expected issuer \"%s\", got \"%s\"\n", expected_issuer, req2->issuer);
+			return -1;
+		}
+	}
+
+	if (errmsg)
+	{
+		if (token)
+		{
+			fprintf(stderr, "--error cannot be combined with --token\n");
+			return -1;
+		}
+		else if (!req2)
+		{
+			fprintf(stderr, "--error cannot be combined with -v1\n");
+			return -1;
+		}
+
+		appendPQExpBufferStr(req2->error, errmsg);
+		return -1;
+	}
+
 	req->token = token;
 	return 1;
 }
@@ -273,6 +348,20 @@ misbehave_cb(PGconn *conn, PGoauthBearerRequest *req, pgsocket *altsock)
 	if (strcmp(misbehave_mode, "fail-async") == 0)
 	{
 		/* Just fail "normally". */
+		if (errmsg)
+		{
+			PGoauthBearerRequestV2 *req2;
+
+			if (hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN)
+			{
+				fprintf(stderr, "--error cannot be combined with -v1\n");
+				exit(1);
+			}
+
+			req2 = (PGoauthBearerRequestV2 *) req;
+			appendPQExpBufferStr(req2->error, errmsg);
+		}
+
 		return PGRES_POLLING_FAILED;
 	}
 	else if (strcmp(misbehave_mode, "no-token") == 0)
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
index e6c91fc911c..0cee72ef58e 100644
--- a/src/test/modules/oauth_validator/t/002_client.pl
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -78,9 +78,9 @@ sub test
 	my $log_start = -s $node->logfile;
 	my ($stdout, $stderr) = run_command(\@cmd);
 
-	if (defined($params{expected_stdout}))
+	if ($params{expect_success})
 	{
-		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
+		like($stdout, qr/connection succeeded/, "$test_name: stdout matches");
 	}
 
 	if (defined($params{expected_stderr}))
@@ -110,11 +110,24 @@ test(
 	flags => [
 		"--token", "my-token",
 		"--expected-uri", "$issuer/.well-known/openid-configuration",
+		"--expected-issuer", "$issuer",
 		"--expected-scope", $scope,
 	],
-	expected_stdout => qr/connection succeeded/,
+	expect_success => 1,
 	log_like => [qr/oauth_validator: token="my-token", role="$user"/]);
 
+# Make sure the v1 hook continues to work. */
+test(
+	"v1 synchronous hook can provide a token",
+	flags => [
+		"-v1",
+		"--token" => "my-token-v1",
+		"--expected-uri" => "$issuer/.well-known/openid-configuration",
+		"--expected-scope" => $scope,
+	],
+	expect_success => 1,
+	log_like => [qr/oauth_validator: token="my-token-v1", role="$user"/]);
+
 if ($ENV{with_libcurl} ne 'yes')
 {
 	# libpq should help users out if no OAuth support is built in.
@@ -126,6 +139,15 @@ if ($ENV{with_libcurl} ne 'yes')
 	);
 }
 
+# v2 synchronous flows should be able to set custom error messages.
+test(
+	"basic synchronous hook can set error messages",
+	flags => [
+		"--error" => "a custom error message",
+	],
+	expected_stderr =>
+	  qr/user-defined OAuth flow failed: a custom error message/);
+
 # connect_timeout should work if the flow doesn't respond.
 $common_connstr = "$common_connstr connect_timeout=1";
 test(
@@ -165,4 +187,14 @@ foreach my $c (@cases)
 		expected_stderr => $c->{'expected_error'});
 }
 
+# v2 async flows should be able to set error messages, too.
+test(
+	"asynchronous hook can set error messages",
+	flags => [
+		"--misbehave" => "fail-async",
+		"--error" => "async error message",
+	],
+	expected_stderr =>
+	  qr/user-defined OAuth flow failed: async error message/);
+
 done_testing();
-- 
2.34.1

0005-libpq-oauth-Use-the-PGoauthBearerRequestV2-API.patchapplication/octet-stream; name=0005-libpq-oauth-Use-the-PGoauthBearerRequestV2-API.patchDownload
From c012bc715e1390fecdc958c984151d06cdad9049 Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Thu, 23 Oct 2025 12:24:20 -0700
Subject: [PATCH 5/7] libpq-oauth: Use the PGoauthBearerRequestV2 API

Switch the private libpq-oauth ABI to the public PGoauthBearerRequestV2
API. A huge amount of glue code can be removed as part of this, and several
code paths can be deduplicated. Additionally, the shared library no
longer needs to change its name for every major release; it's now just
"libpq-oauth.so".

TODO: This API might need a public initialization function in addition
      to the private NLS initialization. Should we expose the lock to
      that function as well?
---
 src/interfaces/libpq-oauth/README        |  59 ++--
 src/interfaces/libpq-oauth/exports.txt   |   3 +-
 src/interfaces/libpq-oauth/meson.build   |   4 +-
 src/interfaces/libpq-oauth/Makefile      |   9 +-
 src/interfaces/libpq-oauth/oauth-curl.h  |   6 +-
 src/interfaces/libpq-oauth/oauth-utils.h |  42 +--
 src/interfaces/libpq/fe-auth-oauth.h     |   7 +-
 src/interfaces/libpq-oauth/oauth-curl.c  | 347 +++++++++++++----------
 src/interfaces/libpq-oauth/oauth-utils.c |  60 +---
 src/interfaces/libpq/fe-auth-oauth.c     | 198 ++++++-------
 10 files changed, 329 insertions(+), 406 deletions(-)

diff --git a/src/interfaces/libpq-oauth/README b/src/interfaces/libpq-oauth/README
index 553962d644e..276e67735f0 100644
--- a/src/interfaces/libpq-oauth/README
+++ b/src/interfaces/libpq-oauth/README
@@ -10,48 +10,35 @@ results in a failed connection.
 
 = Load-Time ABI =
 
-This module ABI is an internal implementation detail, so it's subject to change
-across major releases; the name of the module (libpq-oauth-MAJOR) reflects this.
-The module exports the following symbols:
+As of v19, this module ABI is public and cannot change incompatibly without also
+changing the entry points. Both libpq and libpq-oauth must gracefully handle
+situations where the other library is of a different release version, past or
+future, since upgrades to the libraries may happen in either order.
 
-- PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
-- void pg_fe_cleanup_oauth_flow(PGconn *conn);
+(Don't assume that package version dependencies from libpq-oauth to libpq will
+simplify the situation! Since libpq delay-loads libpq-oauth, we still have to
+handle cases where a long-running client application has a libpq that's older
+than a newly upgraded plugin.)
 
-pg_fe_run_oauth_flow and pg_fe_cleanup_oauth_flow are implementations of
-conn->async_auth and conn->cleanup_async_auth, respectively.
+The module exports the following symbol:
 
-At the moment, pg_fe_run_oauth_flow() relies on libpq's pg_g_threadlock and
-libpq_gettext(), which must be injected by libpq using this initialization
-function before the flow is run:
+- int pg_start_oauthbearer(PGconn *conn, PGoauthBearerRequestV2 *request);
+
+The module then behaves as if it had received a PQAUTHDATA_OAUTH_BEARER_TOKEN_V2
+request via the PQauthDataHook API, and it either fills in an existing token or
+populates the necessary callbacks for a token to be obtained asynchronously.
+(See the documentation for PGoauthBearerRequest.) The function returns zero on
+success and nonzero on failure.
+
+Additionally, libpq-oauth relies on libpq's pg_g_threadlock and libpq_gettext(),
+which must be injected by libpq using this initialization function before the
+flow is run:
 
 - void libpq_oauth_init(pgthreadlock_t threadlock,
-						libpq_gettext_func gettext_impl,
-						conn_errorMessage_func errmsg_impl,
-						conn_oauth_client_id_func clientid_impl,
-						conn_oauth_client_secret_func clientsecret_impl,
-						conn_oauth_discovery_uri_func discoveryuri_impl,
-						conn_oauth_issuer_id_func issuerid_impl,
-						conn_oauth_scope_func scope_impl,
-						conn_sasl_state_func saslstate_impl,
-						set_conn_altsock_func setaltsock_impl,
-						set_conn_oauth_token_func settoken_impl);
-
-It also relies on access to several members of the PGconn struct. Not only can
-these change positions across minor versions, but the offsets aren't necessarily
-stable within a single minor release (conn->errorMessage, for instance, can
-change offsets depending on configure-time options). Therefore the necessary
-accessors (named conn_*) and mutators (set_conn_*) are injected here. With this
-approach, we can safely search the standard dlopen() paths (e.g. RPATH,
-LD_LIBRARY_PATH, the SO cache) for an implementation module to use, even if that
-module wasn't compiled at the same time as libpq -- which becomes especially
-important during "live upgrade" situations where a running libpq application has
-the libpq-oauth module updated out from under it before it's first loaded from
-disk.
+						libpq_gettext_func gettext_impl);
 
 = Static Build =
 
 The static library libpq.a does not perform any dynamic loading. If the builtin
-flow is enabled, the application is expected to link against libpq-oauth.a
-directly to provide the necessary symbols. (libpq.a and libpq-oauth.a must be
-part of the same build. Unlike the dynamic module, there are no translation
-shims provided.)
+flow is enabled, the application is expected to link against libpq-oauth.a to
+provide the necessary symbol, or else implement pg_start_oauthbearer() itself.
diff --git a/src/interfaces/libpq-oauth/exports.txt b/src/interfaces/libpq-oauth/exports.txt
index 6891a83dbf9..7bc12b860d7 100644
--- a/src/interfaces/libpq-oauth/exports.txt
+++ b/src/interfaces/libpq-oauth/exports.txt
@@ -1,4 +1,3 @@
 # src/interfaces/libpq-oauth/exports.txt
 libpq_oauth_init          1
-pg_fe_run_oauth_flow      2
-pg_fe_cleanup_oauth_flow  3
+pg_start_oauthbearer      2
diff --git a/src/interfaces/libpq-oauth/meson.build b/src/interfaces/libpq-oauth/meson.build
index 881e3f24f10..11199100b28 100644
--- a/src/interfaces/libpq-oauth/meson.build
+++ b/src/interfaces/libpq-oauth/meson.build
@@ -35,9 +35,7 @@ libpq_oauth_st = static_library('libpq-oauth',
 
 # This is an internal module; we don't want an SONAME and therefore do not set
 # SO_MAJOR_VERSION.
-libpq_oauth_name = 'libpq-oauth-@0@'.format(pg_version_major)
-
-libpq_oauth_so = shared_module(libpq_oauth_name,
+libpq_oauth_so = shared_module('libpq-oauth',
   libpq_oauth_sources + libpq_oauth_so_sources,
   include_directories: [libpq_oauth_inc, postgres_inc],
   c_args: libpq_oauth_so_c_args,
diff --git a/src/interfaces/libpq-oauth/Makefile b/src/interfaces/libpq-oauth/Makefile
index 51145f085a8..0febb393feb 100644
--- a/src/interfaces/libpq-oauth/Makefile
+++ b/src/interfaces/libpq-oauth/Makefile
@@ -16,13 +16,10 @@ include $(top_builddir)/src/Makefile.global
 PGFILEDESC = "libpq-oauth - device authorization OAuth support"
 
 # This is an internal module; we don't want an SONAME and therefore do not set
-# SO_MAJOR_VERSION.
-NAME = pq-oauth-$(MAJORVERSION)
-
-# Force the name "libpq-oauth" for both the static and shared libraries. The
-# staticlib doesn't need version information in its name.
+# SO_MAJOR_VERSION. This requires an explicit override for the shared library
+# name.
+NAME = pq-oauth
 override shlib := lib$(NAME)$(DLSUFFIX)
-override stlib := libpq-oauth.a
 
 override CPPFLAGS := -I$(libpq_srcdir) -I$(top_builddir)/src/port $(CPPFLAGS) $(LIBCURL_CPPFLAGS)
 override CFLAGS += $(PTHREAD_CFLAGS)
diff --git a/src/interfaces/libpq-oauth/oauth-curl.h b/src/interfaces/libpq-oauth/oauth-curl.h
index 248d0424ad0..47704689586 100644
--- a/src/interfaces/libpq-oauth/oauth-curl.h
+++ b/src/interfaces/libpq-oauth/oauth-curl.h
@@ -17,8 +17,8 @@
 
 #include "libpq-fe.h"
 
-/* Exported async-auth callbacks. */
-extern PGDLLEXPORT PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
-extern PGDLLEXPORT void pg_fe_cleanup_oauth_flow(PGconn *conn);
+/* Exported flow callback. */
+extern PGDLLEXPORT int pg_start_oauthbearer(PGconn *conn,
+											PGoauthBearerRequestV2 *request);
 
 #endif							/* OAUTH_CURL_H */
diff --git a/src/interfaces/libpq-oauth/oauth-utils.h b/src/interfaces/libpq-oauth/oauth-utils.h
index f4ffefef208..343aa95cacf 100644
--- a/src/interfaces/libpq-oauth/oauth-utils.h
+++ b/src/interfaces/libpq-oauth/oauth-utils.h
@@ -15,53 +15,14 @@
 #ifndef OAUTH_UTILS_H
 #define OAUTH_UTILS_H
 
-#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 #include "pqexpbuffer.h"
 
-/*
- * A bank of callbacks to safely access members of PGconn, which are all passed
- * to libpq_oauth_init() by libpq.
- *
- * Keep these aligned with the definitions in fe-auth-oauth.c as well as the
- * static declarations in oauth-curl.c.
- */
-#define DECLARE_GETTER(TYPE, MEMBER) \
-	typedef TYPE (*conn_ ## MEMBER ## _func) (PGconn *conn); \
-	extern conn_ ## MEMBER ## _func conn_ ## MEMBER;
-
-#define DECLARE_SETTER(TYPE, MEMBER) \
-	typedef void (*set_conn_ ## MEMBER ## _func) (PGconn *conn, TYPE val); \
-	extern set_conn_ ## MEMBER ## _func set_conn_ ## MEMBER;
-
-DECLARE_GETTER(PQExpBuffer, errorMessage);
-DECLARE_GETTER(char *, oauth_client_id);
-DECLARE_GETTER(char *, oauth_client_secret);
-DECLARE_GETTER(char *, oauth_discovery_uri);
-DECLARE_GETTER(char *, oauth_issuer_id);
-DECLARE_GETTER(char *, oauth_scope);
-DECLARE_GETTER(fe_oauth_state *, sasl_state);
-
-DECLARE_SETTER(pgsocket, altsock);
-DECLARE_SETTER(char *, oauth_token);
-
-#undef DECLARE_GETTER
-#undef DECLARE_SETTER
-
 typedef char *(*libpq_gettext_func) (const char *msgid);
 
 /* Initializes libpq-oauth. */
 extern PGDLLEXPORT void libpq_oauth_init(pgthreadlock_t threadlock,
-										 libpq_gettext_func gettext_impl,
-										 conn_errorMessage_func errmsg_impl,
-										 conn_oauth_client_id_func clientid_impl,
-										 conn_oauth_client_secret_func clientsecret_impl,
-										 conn_oauth_discovery_uri_func discoveryuri_impl,
-										 conn_oauth_issuer_id_func issuerid_impl,
-										 conn_oauth_scope_func scope_impl,
-										 conn_sasl_state_func saslstate_impl,
-										 set_conn_altsock_func setaltsock_impl,
-										 set_conn_oauth_token_func settoken_impl);
+										 libpq_gettext_func gettext_impl);
 
 /*
  * Duplicated APIs, copied from libpq (primarily libpq-int.h, which we cannot
@@ -75,7 +36,6 @@ typedef enum
 	PG_BOOL_NO					/* No (false) */
 } PGTernaryBool;
 
-extern void libpq_append_conn_error(PGconn *conn, const char *fmt,...) pg_attribute_printf(2, 3);
 extern bool oauth_unsafe_debugging_enabled(void);
 extern int	pq_block_sigpipe(sigset_t *osigset, bool *sigpipe_pending);
 extern void pq_reset_sigpipe(sigset_t *osigset, bool sigpipe_pending, bool got_epipe);
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
index 0d59e91605b..b9aed879e64 100644
--- a/src/interfaces/libpq/fe-auth-oauth.h
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -27,11 +27,6 @@ enum fe_oauth_step
 	FE_OAUTH_SERVER_ERROR,
 };
 
-/*
- * This struct is exported to the libpq-oauth module. If changes are needed
- * during backports to stable branches, please keep ABI compatibility (no
- * changes to existing members, add new members at the end, etc.).
- */
 typedef struct
 {
 	enum fe_oauth_step step;
@@ -39,12 +34,12 @@ typedef struct
 	PGconn	   *conn;
 	void	   *async_ctx;
 
+	bool		builtin;
 	void	   *builtin_flow;
 } fe_oauth_state;
 
 extern void pqClearOAuthToken(PGconn *conn);
 extern bool oauth_unsafe_debugging_enabled(void);
-extern bool use_builtin_flow(PGconn *conn, fe_oauth_state *state);
 
 /* Mechanisms in fe-auth-oauth.c */
 extern const pg_fe_sasl_mech pg_oauth_mech;
diff --git a/src/interfaces/libpq-oauth/oauth-curl.c b/src/interfaces/libpq-oauth/oauth-curl.c
index bd0a656a166..812df147d20 100644
--- a/src/interfaces/libpq-oauth/oauth-curl.c
+++ b/src/interfaces/libpq-oauth/oauth-curl.c
@@ -29,7 +29,6 @@
 #endif
 
 #include "common/jsonapi.h"
-#include "fe-auth-oauth.h"
 #include "mb/pg_wchar.h"
 #include "oauth-curl.h"
 
@@ -44,23 +43,10 @@
 
 #else							/* !USE_DYNAMIC_OAUTH */
 
-/*
- * Static builds may rely on PGconn offsets directly. Keep these aligned with
- * the bank of callbacks in oauth-utils.h.
- */
+/* Static builds may make use of libpq internals directly. */
+#include "fe-auth-oauth.h"
 #include "libpq-int.h"
 
-#define conn_errorMessage(CONN) (&CONN->errorMessage)
-#define conn_oauth_client_id(CONN) (CONN->oauth_client_id)
-#define conn_oauth_client_secret(CONN) (CONN->oauth_client_secret)
-#define conn_oauth_discovery_uri(CONN) (CONN->oauth_discovery_uri)
-#define conn_oauth_issuer_id(CONN) (CONN->oauth_issuer_id)
-#define conn_oauth_scope(CONN) (CONN->oauth_scope)
-#define conn_sasl_state(CONN) (CONN->sasl_state)
-
-#define set_conn_altsock(CONN, VAL) do { CONN->altsock = VAL; } while (0)
-#define set_conn_oauth_token(CONN, VAL) do { CONN->oauth_token = VAL; } while (0)
-
 #endif							/* USE_DYNAMIC_OAUTH */
 
 /* One final guardrail against accidental inclusion... */
@@ -227,6 +213,15 @@ enum OAuthStep
  */
 struct async_ctx
 {
+	/* relevant connection options cached from the PGconn */
+	char	   *client_id;		/* oauth_client_id */
+	char	   *client_secret;	/* oauth_client_secret (may be NULL) */
+
+	/* options cached from the PGoauthBearerRequest (we don't own these) */
+	const char *discovery_uri;
+	const char *issuer_id;
+	const char *scope;
+
 	enum OAuthStep step;		/* where are we in the flow? */
 
 	int			timerfd;		/* descriptor for signaling async timeouts */
@@ -285,7 +280,7 @@ struct async_ctx
  * Tears down the Curl handles and frees the async_ctx.
  */
 static void
-free_async_ctx(PGconn *conn, struct async_ctx *actx)
+free_async_ctx(PGoauthBearerRequestV2 *req, struct async_ctx *actx)
 {
 	/*
 	 * In general, none of the error cases below should ever happen if we have
@@ -303,9 +298,9 @@ free_async_ctx(PGconn *conn, struct async_ctx *actx)
 		CURLMcode	err = curl_multi_remove_handle(actx->curlm, actx->curl);
 
 		if (err)
-			libpq_append_conn_error(conn,
-									"libcurl easy handle removal failed: %s",
-									curl_multi_strerror(err));
+			appendPQExpBuffer(req->error,
+							  libpq_gettext("libcurl easy handle removal failed: %s"),
+							  curl_multi_strerror(err));
 	}
 
 	if (actx->curl)
@@ -323,9 +318,9 @@ free_async_ctx(PGconn *conn, struct async_ctx *actx)
 		CURLMcode	err = curl_multi_cleanup(actx->curlm);
 
 		if (err)
-			libpq_append_conn_error(conn,
-									"libcurl multi handle cleanup failed: %s",
-									curl_multi_strerror(err));
+			appendPQExpBuffer(req->error,
+							  libpq_gettext("libcurl multi handle cleanup failed: %s"),
+							  curl_multi_strerror(err));
 	}
 
 	free_provider(&actx->provider);
@@ -340,29 +335,67 @@ free_async_ctx(PGconn *conn, struct async_ctx *actx)
 	if (actx->timerfd >= 0)
 		close(actx->timerfd);
 
+	free(actx->client_id);
+	free(actx->client_secret);
+
 	free(actx);
 }
 
 /*
- * Release resources used for the asynchronous exchange and disconnect the
- * altsock.
- *
- * This is called either at the end of a successful authentication, or during
- * pqDropConnection(), so we won't leak resources even if PQconnectPoll() never
- * calls us back.
+ * Release resources used for the asynchronous exchange.
  */
-void
-pg_fe_cleanup_oauth_flow(PGconn *conn)
+static void
+pg_fe_cleanup_oauth_flow(PGconn *conn, PGoauthBearerRequest *request)
 {
-	fe_oauth_state *state = conn_sasl_state(conn);
+	struct async_ctx *actx = request->user;
+
+	/* request->cleanup is only set after actx has been allocated. */
+	Assert(actx);
 
-	if (state->async_ctx)
+	free_async_ctx((PGoauthBearerRequestV2 *) request, actx);
+	request->user = NULL;
+
+	/* libpq has made its own copy of the token; clear ours now. */
+	if (request->token)
 	{
-		free_async_ctx(conn, state->async_ctx);
-		state->async_ctx = NULL;
+		explicit_bzero(request->token, strlen(request->token));
+		free(request->token);
+		request->token = NULL;
 	}
+}
+
+/*
+ * Builds an error message from actx and appends it to req->error.
+ */
+static void
+append_actx_error(PGoauthBearerRequestV2 *req, struct async_ctx *actx)
+{
+	PQExpBuffer errbuf = req->error;
+
+	/*
+	 * Assemble the three parts of our error: context, body, and detail. See
+	 * also the documentation for struct async_ctx.
+	 */
+	if (actx->errctx)
+		appendPQExpBuffer(errbuf, "%s: ", libpq_gettext(actx->errctx));
+
+	if (PQExpBufferDataBroken(actx->errbuf))
+		appendPQExpBufferStr(errbuf, libpq_gettext("out of memory"));
+	else
+		appendPQExpBufferStr(errbuf, actx->errbuf.data);
+
+	if (actx->curl_err[0])
+	{
+		appendPQExpBuffer(errbuf, " (libcurl: %s)", actx->curl_err);
 
-	set_conn_altsock(conn, PGINVALID_SOCKET);
+		/* Sometimes libcurl adds a newline to the error buffer. :( */
+		if (errbuf->len >= 2 && errbuf->data[errbuf->len - 2] == '\n')
+		{
+			errbuf->data[errbuf->len - 2] = ')';
+			errbuf->data[errbuf->len - 1] = '\0';
+			errbuf->len--;
+		}
+	}
 }
 
 /*
@@ -2193,7 +2226,7 @@ static bool
 check_issuer(struct async_ctx *actx, PGconn *conn)
 {
 	const struct provider *provider = &actx->provider;
-	const char *oauth_issuer_id = conn_oauth_issuer_id(conn);
+	const char *oauth_issuer_id = actx->issuer_id;
 
 	Assert(oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
 	Assert(provider->issuer);	/* ensured by parse_provider() */
@@ -2296,8 +2329,8 @@ check_for_device_flow(struct async_ctx *actx)
 static bool
 add_client_identification(struct async_ctx *actx, PQExpBuffer reqbody, PGconn *conn)
 {
-	const char *oauth_client_id = conn_oauth_client_id(conn);
-	const char *oauth_client_secret = conn_oauth_client_secret(conn);
+	const char *oauth_client_id = actx->client_id;
+	const char *oauth_client_secret = actx->client_secret;
 
 	bool		success = false;
 	char	   *username = NULL;
@@ -2380,11 +2413,10 @@ cleanup:
 static bool
 start_device_authz(struct async_ctx *actx, PGconn *conn)
 {
-	const char *oauth_scope = conn_oauth_scope(conn);
+	const char *oauth_scope = actx->scope;
 	const char *device_authz_uri = actx->provider.device_authorization_endpoint;
 	PQExpBuffer work_buffer = &actx->work_data;
 
-	Assert(conn_oauth_client_id(conn)); /* ensured by setup_oauth_parameters() */
 	Assert(device_authz_uri);	/* ensured by check_for_device_flow() */
 
 	/* Construct our request body. */
@@ -2472,7 +2504,6 @@ start_token_request(struct async_ctx *actx, PGconn *conn)
 	const char *device_code = actx->authz.device_code;
 	PQExpBuffer work_buffer = &actx->work_data;
 
-	Assert(conn_oauth_client_id(conn)); /* ensured by setup_oauth_parameters() */
 	Assert(token_uri);			/* ensured by parse_provider() */
 	Assert(device_code);		/* ensured by parse_device_authz() */
 
@@ -2651,7 +2682,7 @@ prompt_user(struct async_ctx *actx, PGconn *conn)
  * function will not try to reinitialize Curl on successive calls.
  */
 static bool
-initialize_curl(PGconn *conn)
+initialize_curl(PGoauthBearerRequestV2 *req)
 {
 	/*
 	 * Don't let the compiler play tricks with this variable. In the
@@ -2685,8 +2716,8 @@ initialize_curl(PGconn *conn)
 		goto done;
 	else if (init_successful == PG_BOOL_NO)
 	{
-		libpq_append_conn_error(conn,
-								"curl_global_init previously failed during OAuth setup");
+		appendPQExpBufferStr(req->error,
+							 libpq_gettext("curl_global_init previously failed during OAuth setup"));
 		goto done;
 	}
 
@@ -2704,8 +2735,8 @@ initialize_curl(PGconn *conn)
 	 */
 	if (curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32) != CURLE_OK)
 	{
-		libpq_append_conn_error(conn,
-								"curl_global_init failed during OAuth setup");
+		appendPQExpBufferStr(req->error,
+							 libpq_gettext("curl_global_init failed during OAuth setup"));
 		init_successful = PG_BOOL_NO;
 		goto done;
 	}
@@ -2726,11 +2757,12 @@ initialize_curl(PGconn *conn)
 		 * In a downgrade situation, the damage is already done. Curl global
 		 * state may be corrupted. Be noisy.
 		 */
-		libpq_append_conn_error(conn, "libcurl is no longer thread-safe\n"
-								"\tCurl initialization was reported thread-safe when libpq\n"
-								"\twas compiled, but the currently installed version of\n"
-								"\tlibcurl reports that it is not. Recompile libpq against\n"
-								"\tthe installed version of libcurl.");
+		appendPQExpBufferStr(req->error,
+							 libpq_gettext("libcurl is no longer thread-safe\n"
+										   "\tCurl initialization was reported thread-safe when libpq\n"
+										   "\twas compiled, but the currently installed version of\n"
+										   "\tlibcurl reports that it is not. Recompile libpq against\n"
+										   "\tthe installed version of libcurl."));
 		init_successful = PG_BOOL_NO;
 		goto done;
 	}
@@ -2760,54 +2792,16 @@ done:
  * provider.
  */
 static PostgresPollingStatusType
-pg_fe_run_oauth_flow_impl(PGconn *conn)
+pg_fe_run_oauth_flow_impl(PGconn *conn, PGoauthBearerRequestV2 *request,
+						  int *altsock)
 {
-	fe_oauth_state *state = conn_sasl_state(conn);
-	struct async_ctx *actx;
+	struct async_ctx *actx = request->v1.user;
 	char	   *oauth_token = NULL;
-	PQExpBuffer errbuf;
-
-	if (!initialize_curl(conn))
-		return PGRES_POLLING_FAILED;
-
-	if (!state->async_ctx)
-	{
-		/*
-		 * Create our asynchronous state, and hook it into the upper-level
-		 * OAuth state immediately, so any failures below won't leak the
-		 * context allocation.
-		 */
-		actx = calloc(1, sizeof(*actx));
-		if (!actx)
-		{
-			libpq_append_conn_error(conn, "out of memory");
-			return PGRES_POLLING_FAILED;
-		}
-
-		actx->mux = PGINVALID_SOCKET;
-		actx->timerfd = -1;
-
-		/* Should we enable unsafe features? */
-		actx->debugging = oauth_unsafe_debugging_enabled();
-
-		state->async_ctx = actx;
-
-		initPQExpBuffer(&actx->work_data);
-		initPQExpBuffer(&actx->errbuf);
-
-		if (!setup_multiplexer(actx))
-			goto error_return;
-
-		if (!setup_curl_handles(actx))
-			goto error_return;
-	}
-
-	actx = state->async_ctx;
 
 	do
 	{
 		/* By default, the multiplexer is the altsock. Reassign as desired. */
-		set_conn_altsock(conn, actx->mux);
+		*altsock = actx->mux;
 
 		switch (actx->step)
 		{
@@ -2872,7 +2866,7 @@ pg_fe_run_oauth_flow_impl(PGconn *conn)
 
 					if (!expired)
 					{
-						set_conn_altsock(conn, actx->timerfd);
+						*altsock = actx->timerfd;
 						return PGRES_POLLING_READING;
 					}
 
@@ -2889,7 +2883,7 @@ pg_fe_run_oauth_flow_impl(PGconn *conn)
 		{
 			case OAUTH_STEP_INIT:
 				actx->errctx = "failed to fetch OpenID discovery document";
-				if (!start_discovery(actx, conn_oauth_discovery_uri(conn)))
+				if (!start_discovery(actx, actx->discovery_uri))
 					goto error_return;
 
 				actx->step = OAUTH_STEP_DISCOVERY;
@@ -2929,10 +2923,10 @@ pg_fe_run_oauth_flow_impl(PGconn *conn)
 					goto error_return;
 
 				/*
-				 * Hook any oauth_token into the PGconn immediately so that
-				 * the allocation isn't lost in case of an error.
+				 * Hook any oauth_token into the request struct immediately so
+				 * that the allocation isn't lost in case of an error.
 				 */
-				set_conn_oauth_token(conn, oauth_token);
+				request->v1.token = oauth_token;
 
 				if (!actx->user_prompted)
 				{
@@ -2961,7 +2955,7 @@ pg_fe_run_oauth_flow_impl(PGconn *conn)
 				 * the client wait directly on the timerfd rather than the
 				 * multiplexer.
 				 */
-				set_conn_altsock(conn, actx->timerfd);
+				*altsock = actx->timerfd;
 
 				actx->step = OAUTH_STEP_WAIT_INTERVAL;
 				actx->running = 1;
@@ -2987,48 +2981,21 @@ pg_fe_run_oauth_flow_impl(PGconn *conn)
 	return oauth_token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
 
 error_return:
-	errbuf = conn_errorMessage(conn);
-
-	/*
-	 * Assemble the three parts of our error: context, body, and detail. See
-	 * also the documentation for struct async_ctx.
-	 */
-	if (actx->errctx)
-		appendPQExpBuffer(errbuf, "%s: ", libpq_gettext(actx->errctx));
-
-	if (PQExpBufferDataBroken(actx->errbuf))
-		appendPQExpBufferStr(errbuf, libpq_gettext("out of memory"));
-	else
-		appendPQExpBufferStr(errbuf, actx->errbuf.data);
-
-	if (actx->curl_err[0])
-	{
-		appendPQExpBuffer(errbuf, " (libcurl: %s)", actx->curl_err);
-
-		/* Sometimes libcurl adds a newline to the error buffer. :( */
-		if (errbuf->len >= 2 && errbuf->data[errbuf->len - 2] == '\n')
-		{
-			errbuf->data[errbuf->len - 2] = ')';
-			errbuf->data[errbuf->len - 1] = '\0';
-			errbuf->len--;
-		}
-	}
-
-	appendPQExpBufferChar(errbuf, '\n');
+	append_actx_error(request, actx);
 
 	return PGRES_POLLING_FAILED;
 }
 
 /*
- * The top-level entry point. This is a convenient place to put necessary
- * wrapper logic before handing off to the true implementation, above.
+ * The top-level entry point for the flow. This is a convenient place to put
+ * necessary wrapper logic before handing off to the true implementation, above.
  */
-PostgresPollingStatusType
-pg_fe_run_oauth_flow(PGconn *conn)
+static PostgresPollingStatusType
+pg_fe_run_oauth_flow(PGconn *conn, struct PGoauthBearerRequest *request,
+					 int *altsock)
 {
 	PostgresPollingStatusType result;
-	fe_oauth_state *state = conn_sasl_state(conn);
-	struct async_ctx *actx;
+	struct async_ctx *actx = request->user;
 #ifndef WIN32
 	sigset_t	osigset;
 	bool		sigpipe_pending;
@@ -3055,20 +3022,16 @@ pg_fe_run_oauth_flow(PGconn *conn)
 	masked = (pq_block_sigpipe(&osigset, &sigpipe_pending) == 0);
 #endif
 
-	result = pg_fe_run_oauth_flow_impl(conn);
+	result = pg_fe_run_oauth_flow_impl(conn,
+									   (PGoauthBearerRequestV2 *) request,
+									   altsock);
 
 	/*
 	 * To assist with finding bugs in comb_multiplexer() and
 	 * drain_timer_events(), when we're in debug mode, track the total number
 	 * of calls to this function and print that at the end of the flow.
-	 *
-	 * Be careful that state->async_ctx could be NULL if early initialization
-	 * fails during the first call.
 	 */
-	actx = state->async_ctx;
-	Assert(actx || result == PGRES_POLLING_FAILED);
-
-	if (actx && actx->debugging)
+	if (actx->debugging)
 	{
 		actx->dbg_num_calls++;
 		if (result == PGRES_POLLING_OK || result == PGRES_POLLING_FAILED)
@@ -3089,3 +3052,103 @@ pg_fe_run_oauth_flow(PGconn *conn)
 
 	return result;
 }
+
+/*
+ * Callback registration for OAUTHBEARER. libpq calls this once per OAuth
+ * connection.
+ */
+int
+pg_start_oauthbearer(PGconn *conn, PGoauthBearerRequestV2 *request)
+{
+	struct async_ctx *actx;
+
+	if (!initialize_curl(request))	/* TODO move to init function? */
+		return -1;
+
+	/*
+	 * Create our asynchronous state, and hook it into the upper-level OAuth
+	 * state immediately, so any failures below won't leak the context
+	 * allocation.
+	 */
+	actx = calloc(1, sizeof(*actx));
+	if (!actx)
+		goto oom;
+
+	actx->mux = PGINVALID_SOCKET;
+	actx->timerfd = -1;
+
+	/*
+	 * Now we have a valid (but still useless) actx, so we can fill in the
+	 * request object. From this point onward, failures will result in a call
+	 * to pg_fe_cleanup_oauth_flow(). Further cleanup logic belongs there.
+	 */
+	request->v1.async = pg_fe_run_oauth_flow;
+	request->v1.cleanup = pg_fe_cleanup_oauth_flow;
+	request->v1.user = actx;
+
+	/*
+	 * Now finish filling in the actx.
+	 */
+
+	/* Should we enable unsafe features? */
+	actx->debugging = oauth_unsafe_debugging_enabled();
+
+	initPQExpBuffer(&actx->work_data);
+	initPQExpBuffer(&actx->errbuf);
+
+	/* Pull relevant connection options. */
+	{
+		PQconninfoOption *conninfo = PQconninfo(conn);
+
+		if (!conninfo)
+			goto oom;
+
+		for (PQconninfoOption *opt = conninfo; opt->keyword; opt++)
+		{
+			if (!opt->val)
+				continue;		/* simplifies the strdup logic below */
+
+			if (strcmp(opt->keyword, "oauth_client_id") == 0)
+			{
+				actx->client_id = strdup(opt->val);
+				if (!actx->client_id)
+					goto oom;
+			}
+			else if (strcmp(opt->keyword, "oauth_client_secret") == 0)
+			{
+				actx->client_secret = strdup(opt->val);
+				if (!actx->client_secret)
+					goto oom;
+			}
+		}
+
+		PQconninfoFree(conninfo);
+	}
+
+	actx->discovery_uri = request->v1.openid_configuration;
+	actx->issuer_id = request->issuer;
+	actx->scope = request->v1.scope;
+
+	Assert(actx->client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(actx->issuer_id);	/* ensured by setup_oauth_parameters() */
+	Assert(actx->discovery_uri);	/* ensured by oauth_exchange() */
+
+	if (!setup_multiplexer(actx))
+	{
+		append_actx_error(request, actx);
+		return -1;
+	}
+
+	if (!setup_curl_handles(actx))
+	{
+		append_actx_error(request, actx);
+		return -1;
+	}
+
+	return 0;
+
+oom:
+	appendPQExpBufferStr(request->error,
+						 libpq_gettext("out of memory"));
+	return -1;
+}
diff --git a/src/interfaces/libpq-oauth/oauth-utils.c b/src/interfaces/libpq-oauth/oauth-utils.c
index 45fdc7579f2..f1d30e01cf0 100644
--- a/src/interfaces/libpq-oauth/oauth-utils.c
+++ b/src/interfaces/libpq-oauth/oauth-utils.c
@@ -35,17 +35,6 @@
 pgthreadlock_t pg_g_threadlock;
 static libpq_gettext_func libpq_gettext_impl;
 
-conn_errorMessage_func conn_errorMessage;
-conn_oauth_client_id_func conn_oauth_client_id;
-conn_oauth_client_secret_func conn_oauth_client_secret;
-conn_oauth_discovery_uri_func conn_oauth_discovery_uri;
-conn_oauth_issuer_id_func conn_oauth_issuer_id;
-conn_oauth_scope_func conn_oauth_scope;
-conn_sasl_state_func conn_sasl_state;
-
-set_conn_altsock_func set_conn_altsock;
-set_conn_oauth_token_func set_conn_oauth_token;
-
 /*-
  * Initializes libpq-oauth by setting necessary callbacks.
  *
@@ -63,57 +52,10 @@ set_conn_oauth_token_func set_conn_oauth_token;
  */
 void
 libpq_oauth_init(pgthreadlock_t threadlock_impl,
-				 libpq_gettext_func gettext_impl,
-				 conn_errorMessage_func errmsg_impl,
-				 conn_oauth_client_id_func clientid_impl,
-				 conn_oauth_client_secret_func clientsecret_impl,
-				 conn_oauth_discovery_uri_func discoveryuri_impl,
-				 conn_oauth_issuer_id_func issuerid_impl,
-				 conn_oauth_scope_func scope_impl,
-				 conn_sasl_state_func saslstate_impl,
-				 set_conn_altsock_func setaltsock_impl,
-				 set_conn_oauth_token_func settoken_impl)
+				 libpq_gettext_func gettext_impl)
 {
 	pg_g_threadlock = threadlock_impl;
 	libpq_gettext_impl = gettext_impl;
-	conn_errorMessage = errmsg_impl;
-	conn_oauth_client_id = clientid_impl;
-	conn_oauth_client_secret = clientsecret_impl;
-	conn_oauth_discovery_uri = discoveryuri_impl;
-	conn_oauth_issuer_id = issuerid_impl;
-	conn_oauth_scope = scope_impl;
-	conn_sasl_state = saslstate_impl;
-	set_conn_altsock = setaltsock_impl;
-	set_conn_oauth_token = settoken_impl;
-}
-
-/*
- * Append a formatted string to the error message buffer of the given
- * connection, after translating it.  This is a copy of libpq's internal API.
- */
-void
-libpq_append_conn_error(PGconn *conn, const char *fmt,...)
-{
-	int			save_errno = errno;
-	bool		done;
-	va_list		args;
-	PQExpBuffer errorMessage = conn_errorMessage(conn);
-
-	Assert(fmt[strlen(fmt) - 1] != '\n');
-
-	if (PQExpBufferBroken(errorMessage))
-		return;					/* already failed */
-
-	/* Loop in case we have to retry after enlarging the buffer. */
-	do
-	{
-		errno = save_errno;
-		va_start(args, fmt);
-		done = appendPQExpBufferVA(errorMessage, libpq_gettext(fmt), args);
-		va_end(args);
-	} while (!done);
-
-	appendPQExpBufferChar(errorMessage, '\n');
 }
 
 #ifdef ENABLE_NLS
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
index 265ec43035a..ac68b4bd3ef 100644
--- a/src/interfaces/libpq/fe-auth-oauth.c
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -78,7 +78,7 @@ oauth_init(PGconn *conn, const char *password,
  * This handles only mechanism state tied to the connection lifetime; state
  * stored in state->async_ctx is freed up either immediately after the
  * authentication handshake succeeds, or before the mechanism is cleaned up on
- * failure. See pg_fe_cleanup_oauth_flow() and cleanup_user_oauth_flow().
+ * failure. See pg_fe_cleanup_oauth_flow() and cleanup_oauth_flow().
  */
 static void
 oauth_free(void *opaq)
@@ -669,12 +669,13 @@ cleanup:
 }
 
 /*
- * Helper for handling user flow failures. If the implementation put anything
- * into request->error, it's added to conn->errorMessage here.
+ * Helper for handling flow failures. If the implementation put anything into
+ * request->error, it's added to conn->errorMessage here.
  */
 static void
-report_user_flow_error(PGconn *conn, const PGoauthBearerRequestV2 *request)
+report_flow_error(PGconn *conn, const PGoauthBearerRequestV2 *request)
 {
+	fe_oauth_state *state = conn->sasl_state;
 	const char *errmsg = NULL;
 
 	if (PQExpBufferBroken(request->error))
@@ -682,28 +683,49 @@ report_user_flow_error(PGconn *conn, const PGoauthBearerRequestV2 *request)
 	else if (request->error->len)
 		errmsg = request->error->data;
 
-	appendPQExpBufferStr(&conn->errorMessage,
-						 libpq_gettext("user-defined OAuth flow failed"));
-
-	if (errmsg)
+	/*
+	 * User-defined flows are called out explicitly so that the user knows who
+	 * to blame. Builtin flows don't need that extra message length; we expect
+	 * them to always fill in request->error on failure anyway.
+	 */
+	if (state->builtin)
 	{
-		appendPQExpBufferStr(&conn->errorMessage, ": ");
+		if (!errmsg)
+		{
+			/*
+			 * Don't turn a bug here into a crash in production, but don't
+			 * bother translating either.
+			 */
+			Assert(false);
+			errmsg = "builtin flow failed but did not provide an error message";
+		}
+
 		appendPQExpBufferStr(&conn->errorMessage, errmsg);
 	}
+	else
+	{
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext("user-defined OAuth flow failed"));
+		if (errmsg)
+		{
+			appendPQExpBufferStr(&conn->errorMessage, ": ");
+			appendPQExpBufferStr(&conn->errorMessage, errmsg);
+		}
+	}
 
 	appendPQExpBufferChar(&conn->errorMessage, '\n');
 }
 
 /*
- * Callback implementation of conn->async_auth() for a user-defined OAuth flow.
- * Delegates the retrieval of the token to the application's async callback.
+ * Callback implementation of conn->async_auth() for OAuth flows. Delegates the
+ * retrieval of the token to the PGoauthBearerRequestV2.async() callback.
  *
- * This will be called multiple times as needed; the application is responsible
- * for setting an altsock to signal and returning the correct PGRES_POLLING_*
+ * This will be called multiple times as needed; the callback is responsible for
+ * setting an altsock to signal and returning the correct PGRES_POLLING_*
  * statuses for use by PQconnectPoll().
  */
 static PostgresPollingStatusType
-run_user_oauth_flow(PGconn *conn)
+run_oauth_flow(PGconn *conn)
 {
 	fe_oauth_state *state = conn->sasl_state;
 	PGoauthBearerRequestV2 *request = state->async_ctx;
@@ -711,6 +733,7 @@ run_user_oauth_flow(PGconn *conn)
 
 	if (!request->v1.async)
 	{
+		Assert(!state->builtin);	/* be very noisy if our code does this */
 		libpq_append_conn_error(conn,
 								"user-defined OAuth flow provided neither a token nor an async callback");
 		return PGRES_POLLING_FAILED;
@@ -722,7 +745,7 @@ run_user_oauth_flow(PGconn *conn)
 
 	if (status == PGRES_POLLING_FAILED)
 	{
-		report_user_flow_error(conn, request);
+		report_flow_error(conn, request);
 		return status;
 	}
 	else if (status == PGRES_POLLING_OK)
@@ -734,6 +757,7 @@ run_user_oauth_flow(PGconn *conn)
 		 */
 		if (!request->v1.token)
 		{
+			Assert(!state->builtin);
 			libpq_append_conn_error(conn,
 									"user-defined OAuth flow did not provide a token");
 			return PGRES_POLLING_FAILED;
@@ -752,6 +776,7 @@ run_user_oauth_flow(PGconn *conn)
 	/* The hook wants the client to poll the altsock. Make sure it set one. */
 	if (conn->altsock == PGINVALID_SOCKET)
 	{
+		Assert(!state->builtin);
 		libpq_append_conn_error(conn,
 								"user-defined OAuth flow did not provide a socket for polling");
 		return PGRES_POLLING_FAILED;
@@ -761,12 +786,16 @@ run_user_oauth_flow(PGconn *conn)
 }
 
 /*
- * Cleanup callback for the async user flow. Delegates most of its job to
+ * Cleanup callback for the async flow. Delegates most of its job to
  * PGoauthBearerRequestV2.cleanup(), then disconnects the altsock and frees the
  * request itself.
+ *
+ * This is called either at the end of a successful authentication, or during
+ * pqDropConnection(), so we won't leak resources even if PQconnectPoll() never
+ * calls us back.
  */
 static void
-cleanup_user_oauth_flow(PGconn *conn)
+cleanup_oauth_flow(PGconn *conn)
 {
 	fe_oauth_state *state = conn->sasl_state;
 	PGoauthBearerRequestV2 *request = state->async_ctx;
@@ -787,12 +816,16 @@ cleanup_user_oauth_flow(PGconn *conn)
  *
  * There are three potential implementations of use_builtin_flow:
  *
- * 1) If the OAuth client is disabled at configuration time, return false.
+ * 1) If the OAuth client is disabled at configuration time, return zero.
  *    Dependent clients must provide their own flow.
  * 2) If the OAuth client is enabled and USE_DYNAMIC_OAUTH is defined, dlopen()
  *    the libpq-oauth plugin and use its implementation.
  * 3) Otherwise, use flow callbacks that are statically linked into the
  *    executable.
+ *
+ * For caller convenience, the return value follows the convention of
+ * PQauthDataHook: zero means no implementation is provided, negative indicates
+ * failure, and positive indicates success.
  */
 
 #if !defined(USE_LIBCURL)
@@ -801,10 +834,10 @@ cleanup_user_oauth_flow(PGconn *conn)
  * This configuration doesn't support the builtin flow.
  */
 
-bool
-use_builtin_flow(PGconn *conn, fe_oauth_state *state)
+static int
+use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *request)
 {
-	return false;
+	return 0;
 }
 
 #elif defined(USE_DYNAMIC_OAUTH)
@@ -815,36 +848,6 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state)
 
 typedef char *(*libpq_gettext_func) (const char *msgid);
 
-/*
- * Define accessor/mutator shims to inject into libpq-oauth, so that it doesn't
- * depend on the offsets within PGconn. (These have changed during minor version
- * updates in the past.)
- */
-
-#define DEFINE_GETTER(TYPE, MEMBER) \
-	typedef TYPE (*conn_ ## MEMBER ## _func) (PGconn *conn); \
-	static TYPE conn_ ## MEMBER(PGconn *conn) { return conn->MEMBER; }
-
-/* Like DEFINE_GETTER, but returns a pointer to the member. */
-#define DEFINE_GETTER_P(TYPE, MEMBER) \
-	typedef TYPE (*conn_ ## MEMBER ## _func) (PGconn *conn); \
-	static TYPE conn_ ## MEMBER(PGconn *conn) { return &conn->MEMBER; }
-
-#define DEFINE_SETTER(TYPE, MEMBER) \
-	typedef void (*set_conn_ ## MEMBER ## _func) (PGconn *conn, TYPE val); \
-	static void set_conn_ ## MEMBER(PGconn *conn, TYPE val) { conn->MEMBER = val; }
-
-DEFINE_GETTER_P(PQExpBuffer, errorMessage);
-DEFINE_GETTER(char *, oauth_client_id);
-DEFINE_GETTER(char *, oauth_client_secret);
-DEFINE_GETTER(char *, oauth_discovery_uri);
-DEFINE_GETTER(char *, oauth_issuer_id);
-DEFINE_GETTER(char *, oauth_scope);
-DEFINE_GETTER(fe_oauth_state *, sasl_state);
-
-DEFINE_SETTER(pgsocket, altsock);
-DEFINE_SETTER(char *, oauth_token);
-
 /*
  * Loads the libpq-oauth plugin via dlopen(), initializes it, and plugs its
  * callbacks into the connection's async auth handlers.
@@ -853,27 +856,20 @@ DEFINE_SETTER(char *, oauth_token);
  * handle the use case where the build supports loading a flow but a user does
  * not want to install it. Troubleshooting of linker/loader failures can be done
  * via PGOAUTHDEBUG.
+ *
+ * The lifetime of *request ends shortly after this call, so it must be copied
+ * to longer-lived storage.
  */
-bool
-use_builtin_flow(PGconn *conn, fe_oauth_state *state)
+static int
+use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *request)
 {
 	static bool initialized = false;
 	static pthread_mutex_t init_mutex = PTHREAD_MUTEX_INITIALIZER;
 	int			lockerr;
 
 	void		(*init) (pgthreadlock_t threadlock,
-						 libpq_gettext_func gettext_impl,
-						 conn_errorMessage_func errmsg_impl,
-						 conn_oauth_client_id_func clientid_impl,
-						 conn_oauth_client_secret_func clientsecret_impl,
-						 conn_oauth_discovery_uri_func discoveryuri_impl,
-						 conn_oauth_issuer_id_func issuerid_impl,
-						 conn_oauth_scope_func scope_impl,
-						 conn_sasl_state_func saslstate_impl,
-						 set_conn_altsock_func setaltsock_impl,
-						 set_conn_oauth_token_func settoken_impl);
-	PostgresPollingStatusType (*flow) (PGconn *conn);
-	void		(*cleanup) (PGconn *conn);
+						 libpq_gettext_func gettext_impl);
+	int			(*start_flow) (PGconn *conn, PGoauthBearerRequestV2 *request);
 
 	/*
 	 * On macOS only, load the module using its absolute install path; the
@@ -886,9 +882,9 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state)
 	 */
 	const char *const module_name =
 #if defined(__darwin__)
-		LIBDIR "/libpq-oauth-" PG_MAJORVERSION DLSUFFIX;
+		LIBDIR "/libpq-oauth" DLSUFFIX;
 #else
-		"libpq-oauth-" PG_MAJORVERSION DLSUFFIX;
+		"libpq-oauth" DLSUFFIX;
 #endif
 
 	state->builtin_flow = dlopen(module_name, RTLD_NOW | RTLD_LOCAL);
@@ -904,12 +900,11 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state)
 		if (oauth_unsafe_debugging_enabled())
 			fprintf(stderr, "failed dlopen for libpq-oauth: %s\n", dlerror());
 
-		return false;
+		return 0;
 	}
 
 	if ((init = dlsym(state->builtin_flow, "libpq_oauth_init")) == NULL
-		|| (flow = dlsym(state->builtin_flow, "pg_fe_run_oauth_flow")) == NULL
-		|| (cleanup = dlsym(state->builtin_flow, "pg_fe_cleanup_oauth_flow")) == NULL)
+		|| (start_flow = dlsym(state->builtin_flow, "pg_start_oauthbearer")) == NULL)
 	{
 		/*
 		 * This is more of an error condition than the one above, but due to
@@ -919,7 +914,7 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state)
 			fprintf(stderr, "failed dlsym for libpq-oauth: %s\n", dlerror());
 
 		dlclose(state->builtin_flow);
-		return false;
+		return 0;
 	}
 
 	/*
@@ -939,56 +934,37 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state)
 		Assert(false);
 
 		libpq_append_conn_error(conn, "failed to lock mutex (%d)", lockerr);
-		return false;
+		return 0;
 	}
 
 	if (!initialized)
 	{
 		init(pg_g_threadlock,
 #ifdef ENABLE_NLS
-			 libpq_gettext,
+			 libpq_gettext
 #else
-			 NULL,
+			 NULL
 #endif
-			 conn_errorMessage,
-			 conn_oauth_client_id,
-			 conn_oauth_client_secret,
-			 conn_oauth_discovery_uri,
-			 conn_oauth_issuer_id,
-			 conn_oauth_scope,
-			 conn_sasl_state,
-			 set_conn_altsock,
-			 set_conn_oauth_token);
+			);
 
 		initialized = true;
 	}
 
 	pthread_mutex_unlock(&init_mutex);
 
-	/* Set our asynchronous callbacks. */
-	conn->async_auth = flow;
-	conn->cleanup_async_auth = cleanup;
-
-	return true;
+	return (start_flow(conn, request) == 0) ? 1 : -1;
 }
 
 #else
 
 /*
- * Use the builtin flow in libpq-oauth.a (see libpq-oauth/oauth-curl.h).
+ * For static builds, we can just call pg_start_oauthbearer() directly. It's
+ * provided by libpq-oauth.a.
  */
-
-extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
-extern void pg_fe_cleanup_oauth_flow(PGconn *conn);
-
-bool
-use_builtin_flow(PGconn *conn, fe_oauth_state *state)
+static int
+use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *request)
 {
-	/* Set our asynchronous callbacks. */
-	conn->async_auth = pg_fe_run_oauth_flow;
-	conn->cleanup_async_auth = pg_fe_cleanup_oauth_flow;
-
-	return true;
+	return (pg_start_oauthbearer(conn, request) == 0) ? 1 : -1;
 }
 
 #endif							/* USE_LIBCURL */
@@ -1001,11 +977,11 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state)
  * If the application has registered a custom flow handler using
  * PQAUTHDATA_OAUTH_BEARER_TOKEN[_V2], it may either return a token immediately
  * (e.g. if it has one cached for immediate use), or set up for a series of
- * asynchronous callbacks which will be managed by run_user_oauth_flow().
+ * asynchronous callbacks which will be managed by run_oauth_flow().
  *
  * If the default handler is used instead, a Device Authorization flow is used
- * for the connection if support has been compiled in. (See
- * fe-auth-oauth-curl.c for implementation details.)
+ * for the connection if support has been compiled in. (See oauth-curl.c for
+ * implementation details.)
  *
  * If neither a custom handler nor the builtin flow is available, the connection
  * fails here.
@@ -1034,11 +1010,17 @@ setup_token_request(PGconn *conn, fe_oauth_state *state)
 
 	/*
 	 * The client may have overridden the OAuth flow. Try the v2 hook first,
-	 * then fall back to the v1 implementation.
+	 * then fall back to the v1 implementation. If neither is available, try
+	 * the builtin flow.
 	 */
 	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN_V2, conn, &request);
 	if (res == 0)
 		res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
+	if (res == 0)
+	{
+		state->builtin = true;
+		res = use_builtin_flow(conn, state, &request);
+	}
 
 	if (res > 0)
 	{
@@ -1074,16 +1056,16 @@ setup_token_request(PGconn *conn, fe_oauth_state *state)
 
 		*request_copy = request;
 
-		conn->async_auth = run_user_oauth_flow;
-		conn->cleanup_async_auth = cleanup_user_oauth_flow;
+		conn->async_auth = run_oauth_flow;
+		conn->cleanup_async_auth = cleanup_oauth_flow;
 		state->async_ctx = request_copy;
 	}
 	else if (res < 0)
 	{
-		report_user_flow_error(conn, &request);
+		report_flow_error(conn, &request);
 		goto fail;
 	}
-	else if (!use_builtin_flow(conn, state))
+	else
 	{
 		libpq_append_conn_error(conn, "no OAuth flows are available (try installing the libpq-oauth package)");
 		goto fail;
-- 
2.34.1

0006-libpq-oauth-Never-link-against-libpq-s-encoding-func.patchapplication/octet-stream; name=0006-libpq-oauth-Never-link-against-libpq-s-encoding-func.patchDownload
From dd491541a01e29feae08f8ee14a2e27b006694a8 Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Wed, 3 Dec 2025 09:53:44 -0800
Subject: [PATCH 6/7] libpq-oauth: Never link against libpq's encoding
 functions

Now that libpq-oauth doesn't have to match the major version of libpq,
some things in pg_wchar.h are technically unsafe for us to use. (See
b6c7cfac8 for a fuller discussion.) This is unlikely to be a problem --
we only care about UTF-8 in the context of OAuth right now -- but if
anyone did introduce a way to hit it, it'd be extremely difficult to
debug or reproduce, and it'd be a potential security vulnerability to
boot.

Define USE_PRIVATE_ENCODING_FUNCS so that anyone who tries to add a
dependency on the exported APIs will simply fail to link the shared
module.
---
 src/interfaces/libpq-oauth/meson.build | 10 +++++++++-
 src/interfaces/libpq-oauth/Makefile    | 11 +++++++++--
 2 files changed, 18 insertions(+), 3 deletions(-)

diff --git a/src/interfaces/libpq-oauth/meson.build b/src/interfaces/libpq-oauth/meson.build
index 11199100b28..b755990490d 100644
--- a/src/interfaces/libpq-oauth/meson.build
+++ b/src/interfaces/libpq-oauth/meson.build
@@ -12,7 +12,15 @@ libpq_oauth_sources = files(
 libpq_oauth_so_sources = files(
   'oauth-utils.c',
 )
-libpq_oauth_so_c_args = ['-DUSE_DYNAMIC_OAUTH']
+libpq_oauth_so_c_args = [
+  '-DUSE_DYNAMIC_OAUTH',
+
+  # A bit of forward-looking paranoia: don't allow anyone to accidentally depend
+  # on the encoding IDs coming from libpq. They're not guaranteed to match the
+  # IDs in use by our version of pgcommon, now that we allow the major version
+  # of libpq to differ from the major version of libpq-oauth.
+  '-DUSE_PRIVATE_ENCODING_FUNCS',
+]
 
 export_file = custom_target('libpq-oauth.exports',
   kwargs: gen_export_kwargs,
diff --git a/src/interfaces/libpq-oauth/Makefile b/src/interfaces/libpq-oauth/Makefile
index 0febb393feb..4572fe780d0 100644
--- a/src/interfaces/libpq-oauth/Makefile
+++ b/src/interfaces/libpq-oauth/Makefile
@@ -24,6 +24,14 @@ override shlib := lib$(NAME)$(DLSUFFIX)
 override CPPFLAGS := -I$(libpq_srcdir) -I$(top_builddir)/src/port $(CPPFLAGS) $(LIBCURL_CPPFLAGS)
 override CFLAGS += $(PTHREAD_CFLAGS)
 
+override CPPFLAGS_SHLIB := -DUSE_DYNAMIC_OAUTH
+
+# A bit of forward-looking paranoia: don't allow libpq-oauth.so to accidentally
+# depend on the encoding IDs coming from libpq. They're not guaranteed to match
+# the IDs in use by our version of pgcommon, now that we allow the major version
+# of libpq to differ from the major version of libpq-oauth.
+override CPPFLAGS_SHLIB += -DUSE_PRIVATE_ENCODING_FUNCS
+
 OBJS = \
 	$(WIN32RES)
 
@@ -34,8 +42,7 @@ OBJS_SHLIB = \
 	oauth-curl_shlib.o \
 	oauth-utils.o \
 
-oauth-utils.o: override CPPFLAGS += -DUSE_DYNAMIC_OAUTH
-oauth-curl_shlib.o: override CPPFLAGS_SHLIB += -DUSE_DYNAMIC_OAUTH
+oauth-utils.o: override CPPFLAGS += $(CPPFLAGS_SHLIB)
 
 # Add shlib-/stlib-specific objects.
 $(shlib): override OBJS += $(OBJS_SHLIB)
-- 
2.34.1

0007-WIP-Introduce-third-party-OAuth-flow-plugins.patchapplication/octet-stream; name=0007-WIP-Introduce-third-party-OAuth-flow-plugins.patchDownload
From eda0c6f9059fe1007b6e227e18b84ff1952c4dfc Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Wed, 3 Dec 2025 15:47:23 -0800
Subject: [PATCH 7/7] WIP: Introduce third-party OAuth flow plugins?

This experimental commit promotes the pg_start_oauthbearer API to a
public header (libpq-oauth.h) and adds a PGOAUTHMODULE environment
variable that overrides the load path for the plugin, allowing users to
provide their own. The libpq_oauth_init function is now optional, and
will remain undocumented. (Modules that don't provide it are marked as
user-defined.)

This is a relatively small amount of implementation change, but
unfortunately the tests have a large amount of code motion to be able to
share logic between the test executable and plugin. I might need to
split that into multiple squash! commits to make it more easily
reviewable.

TODO: figure out PGDLLEXPORT, which we do not currently provide publicly
TODO: add a public init() API so that no one tries to implement
      libpq_oauth_init()?
TODO: lock down PGOAUTHMODULE as necessary to avoid introducing exciting
      new vulnerabilities
TODO: how hard would it be to support Windows here?
---
 src/interfaces/libpq/meson.build              |   1 +
 src/interfaces/libpq/Makefile                 |   2 +
 src/interfaces/libpq-oauth/oauth-curl.h       |  24 --
 src/interfaces/libpq/fe-auth-oauth.h          |   2 +-
 src/interfaces/libpq/libpq-oauth.h            |  52 +++
 src/interfaces/libpq-oauth/oauth-curl.c       |   2 +-
 src/interfaces/libpq/fe-auth-oauth.c          |  81 ++--
 src/test/modules/oauth_validator/meson.build  |  15 +
 src/test/modules/oauth_validator/Makefile     |  10 +-
 .../oauth_validator/oauth_test_common.h       |  26 ++
 src/test/modules/oauth_validator/oauth_flow.c |  69 ++++
 .../oauth_validator/oauth_hook_client.c       | 319 +--------------
 .../oauth_validator/oauth_test_common.c       | 374 ++++++++++++++++++
 .../modules/oauth_validator/t/002_client.pl   |  41 +-
 14 files changed, 649 insertions(+), 369 deletions(-)
 delete mode 100644 src/interfaces/libpq-oauth/oauth-curl.h
 create mode 100644 src/interfaces/libpq/libpq-oauth.h
 create mode 100644 src/test/modules/oauth_validator/oauth_test_common.h
 create mode 100644 src/test/modules/oauth_validator/oauth_flow.c
 create mode 100644 src/test/modules/oauth_validator/oauth_test_common.c

diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index a74e885b169..7e1bd651cde 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -110,6 +110,7 @@ pkgconfig.generate(
 install_headers(
   'libpq-fe.h',
   'libpq-events.h',
+  'libpq-oauth.h',
 )
 
 install_headers(
diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile
index da6650066d4..ccfe60a039e 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -166,6 +166,7 @@ $(top_builddir)/src/port/pg_config_paths.h:
 install: all installdirs install-lib
 	$(INSTALL_DATA) $(srcdir)/libpq-fe.h '$(DESTDIR)$(includedir)'
 	$(INSTALL_DATA) $(srcdir)/libpq-events.h '$(DESTDIR)$(includedir)'
+	$(INSTALL_DATA) $(srcdir)/libpq-oauth.h '$(DESTDIR)$(includedir)'
 	$(INSTALL_DATA) $(srcdir)/libpq-int.h '$(DESTDIR)$(includedir_internal)'
 	$(INSTALL_DATA) $(srcdir)/fe-auth-sasl.h '$(DESTDIR)$(includedir_internal)'
 	$(INSTALL_DATA) $(srcdir)/pqexpbuffer.h '$(DESTDIR)$(includedir_internal)'
@@ -188,6 +189,7 @@ installdirs: installdirs-lib
 uninstall: uninstall-lib
 	rm -f '$(DESTDIR)$(includedir)/libpq-fe.h'
 	rm -f '$(DESTDIR)$(includedir)/libpq-events.h'
+	rm -f '$(DESTDIR)$(includedir)/libpq-oauth.h'
 	rm -f '$(DESTDIR)$(includedir_internal)/libpq-int.h'
 	rm -f '$(DESTDIR)$(includedir_internal)/fe-auth-sasl.h'
 	rm -f '$(DESTDIR)$(includedir_internal)/pqexpbuffer.h'
diff --git a/src/interfaces/libpq-oauth/oauth-curl.h b/src/interfaces/libpq-oauth/oauth-curl.h
deleted file mode 100644
index 47704689586..00000000000
--- a/src/interfaces/libpq-oauth/oauth-curl.h
+++ /dev/null
@@ -1,24 +0,0 @@
-/*-------------------------------------------------------------------------
- *
- * oauth-curl.h
- *
- *	  Definitions for OAuth Device Authorization module
- *
- * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
- * Portions Copyright (c) 1994, Regents of the University of California
- *
- * src/interfaces/libpq-oauth/oauth-curl.h
- *
- *-------------------------------------------------------------------------
- */
-
-#ifndef OAUTH_CURL_H
-#define OAUTH_CURL_H
-
-#include "libpq-fe.h"
-
-/* Exported flow callback. */
-extern PGDLLEXPORT int pg_start_oauthbearer(PGconn *conn,
-											PGoauthBearerRequestV2 *request);
-
-#endif							/* OAUTH_CURL_H */
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
index b9aed879e64..30d3ff6741e 100644
--- a/src/interfaces/libpq/fe-auth-oauth.h
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -35,7 +35,7 @@ typedef struct
 	void	   *async_ctx;
 
 	bool		builtin;
-	void	   *builtin_flow;
+	void	   *flow_module;
 } fe_oauth_state;
 
 extern void pqClearOAuthToken(PGconn *conn);
diff --git a/src/interfaces/libpq/libpq-oauth.h b/src/interfaces/libpq/libpq-oauth.h
new file mode 100644
index 00000000000..2a62b330b1c
--- /dev/null
+++ b/src/interfaces/libpq/libpq-oauth.h
@@ -0,0 +1,52 @@
+/*-------------------------------------------------------------------------
+ *
+ * libpq-oauth.h
+ *	  This file contains structs and functions used by custom OAuth plugins.
+ *
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ *
+ * src/interfaces/libpq/libpq-oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef LIBPQ_OAUTH_H
+#define LIBPQ_OAUTH_H
+
+#include "libpq-fe.h"
+
+#ifdef __cplusplus
+extern "C"
+{
+#endif
+
+/* XXX can't rely on c.h, but duplicating this is asking for trouble */
+#ifndef PGDLLEXPORT
+#ifdef _WIN32
+#define PGDLLEXPORT __declspec (dllexport)
+#elif defined(__has_attribute)
+#if __has_attribute(visibility)
+#define PGDLLEXPORT __attribute__((visibility("default")))
+#else
+#define PGDLLEXPORT
+#endif
+#else
+#define PGDLLEXPORT
+#endif
+#endif
+
+/*
+ * V1 API
+ *
+ * Flow plugins must provide an implementation of this callback.
+ *
+ * TODO: provide a magic struct that allows backwards but not forwards compat?
+ */
+extern PGDLLEXPORT int pg_start_oauthbearer(PGconn *conn,
+											PGoauthBearerRequestV2 *request);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif							/* LIBPQ_OAUTH_H */
diff --git a/src/interfaces/libpq-oauth/oauth-curl.c b/src/interfaces/libpq-oauth/oauth-curl.c
index 812df147d20..ffe2e793225 100644
--- a/src/interfaces/libpq-oauth/oauth-curl.c
+++ b/src/interfaces/libpq-oauth/oauth-curl.c
@@ -29,8 +29,8 @@
 #endif
 
 #include "common/jsonapi.h"
+#include "libpq-oauth.h"
 #include "mb/pg_wchar.h"
-#include "oauth-curl.h"
 
 #ifdef USE_DYNAMIC_OAUTH
 
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
index ac68b4bd3ef..2894cd995b6 100644
--- a/src/interfaces/libpq/fe-auth-oauth.c
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -17,6 +17,8 @@
 
 #ifdef USE_DYNAMIC_OAUTH
 #include <dlfcn.h>
+#else
+#include "libpq-oauth.h"
 #endif
 
 #include "common/base64.h"
@@ -880,15 +882,33 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *re
 	 * On the other platforms, load the module using only the basename, to
 	 * rely on the runtime linker's standard search behavior.
 	 */
-	const char *const module_name =
+	const char *module_name =
 #if defined(__darwin__)
 		LIBDIR "/libpq-oauth" DLSUFFIX;
 #else
 		"libpq-oauth" DLSUFFIX;
 #endif
 
-	state->builtin_flow = dlopen(module_name, RTLD_NOW | RTLD_LOCAL);
-	if (!state->builtin_flow)
+	/*-
+	 * Additionally, the user may override the module path explicitly to be
+	 * able to provide their own module, via PGOAUTHMODULE.
+	 *
+	 * TODO: error messages below need to be rethought when this is set
+	 * TODO: have to think about _all_ the security ramifications of this. What
+	 * existing protections in LD_LIBRARY_PATH (and/or SIP) are we potentially
+	 * bypassing? Should we check the permissions of the file somehow...?
+	 * TODO: maybe disallow anything not underneath LIBDIR? or PKGLIBDIR?
+	 * Should it have a naming convention?
+	 */
+	{
+		const char *env = getenv("PGOAUTHMODULE");
+
+		if (env && env[0])
+			module_name = env;
+	}
+
+	state->flow_module = dlopen(module_name, RTLD_NOW | RTLD_LOCAL);
+	if (!state->flow_module)
 	{
 		/*
 		 * For end users, this probably isn't an error condition, it just
@@ -903,8 +923,7 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *re
 		return 0;
 	}
 
-	if ((init = dlsym(state->builtin_flow, "libpq_oauth_init")) == NULL
-		|| (start_flow = dlsym(state->builtin_flow, "pg_start_oauthbearer")) == NULL)
+	if ((start_flow = dlsym(state->flow_module, "pg_start_oauthbearer")) == NULL)
 	{
 		/*
 		 * This is more of an error condition than the one above, but due to
@@ -913,7 +932,7 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *re
 		if (oauth_unsafe_debugging_enabled())
 			fprintf(stderr, "failed dlsym for libpq-oauth: %s\n", dlerror());
 
-		dlclose(state->builtin_flow);
+		dlclose(state->flow_module);
 		return 0;
 	}
 
@@ -923,34 +942,46 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *re
 	 */
 
 	/*
-	 * We need to inject necessary function pointers into the module. This
-	 * only needs to be done once -- even if the pointers are constant,
-	 * assigning them while another thread is executing the flows feels like
-	 * tempting fate.
+	 * Our libpq-oauth.so provides a special initialization function for libpq
+	 * integration. It's not a problem if we don't find this; it just means
+	 * that a user-defined PGOAUTHMODULE is being used.
 	 */
-	if ((lockerr = pthread_mutex_lock(&init_mutex)) != 0)
+	init = dlsym(state->flow_module, "libpq_oauth_init");
+
+	if (!init)
+		state->builtin = false; /* adjust our error messages */
+	else
 	{
-		/* Should not happen... but don't continue if it does. */
-		Assert(false);
+		/*
+		 * We need to inject necessary function pointers into the module. This
+		 * only needs to be done once -- even if the pointers are constant,
+		 * assigning them while another thread is executing the flows feels
+		 * like tempting fate.
+		 */
+		if ((lockerr = pthread_mutex_lock(&init_mutex)) != 0)
+		{
+			/* Should not happen... but don't continue if it does. */
+			Assert(false);
 
-		libpq_append_conn_error(conn, "failed to lock mutex (%d)", lockerr);
-		return 0;
-	}
+			libpq_append_conn_error(conn, "failed to lock mutex (%d)", lockerr);
+			return 0;
+		}
 
-	if (!initialized)
-	{
-		init(pg_g_threadlock,
+		if (!initialized)
+		{
+			init(pg_g_threadlock,
 #ifdef ENABLE_NLS
-			 libpq_gettext
+				 libpq_gettext
 #else
-			 NULL
+				 NULL
 #endif
-			);
+				);
 
-		initialized = true;
-	}
+			initialized = true;
+		}
 
-	pthread_mutex_unlock(&init_mutex);
+		pthread_mutex_unlock(&init_mutex);
+	}
 
 	return (start_flow(conn, request) == 0) ? 1 : -1;
 }
diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build
index a6f937fd7d7..1d898270220 100644
--- a/src/test/modules/oauth_validator/meson.build
+++ b/src/test/modules/oauth_validator/meson.build
@@ -50,6 +50,7 @@ test_install_libs += magic_validator
 
 oauth_hook_client_sources = files(
   'oauth_hook_client.c',
+  'oauth_test_common.c',
 )
 
 if host_system == 'windows'
@@ -67,6 +68,19 @@ oauth_hook_client = executable('oauth_hook_client',
 )
 testprep_targets += oauth_hook_client
 
+oauth_flow = shared_module('oauth_flow',
+  files(
+    'oauth_flow.c',
+    'oauth_test_common.c',
+  ),
+  include_directories: [postgres_inc],
+  dependencies: [frontend_shlib_code, libpq],
+  kwargs: default_lib_args + {
+    'install': false,
+  },
+)
+testprep_targets += oauth_flow
+
 tests += {
   'name': 'oauth_validator',
   'sd': meson.current_source_dir(),
@@ -80,6 +94,7 @@ tests += {
       'PYTHON': python.full_path(),
       'with_libcurl': oauth_flow_supported ? 'yes' : 'no',
       'with_python': 'yes',
+      'flow_module_path': oauth_flow.full_path(),
     },
   },
 }
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
index 05b9f06ed73..e22098dacb8 100644
--- a/src/test/modules/oauth_validator/Makefile
+++ b/src/test/modules/oauth_validator/Makefile
@@ -14,11 +14,13 @@ PGFILEDESC = "validator - test OAuth validator module"
 
 PROGRAM = oauth_hook_client
 PGAPPICON = win32
-OBJS = $(WIN32RES) oauth_hook_client.o
+OBJS = $(WIN32RES) oauth_hook_client.o oauth_test_common.o
 
 PG_CPPFLAGS = -I$(libpq_srcdir)
 PG_LIBS_INTERNAL += $(libpq_pgport)
 
+EXTRA_CLEAN = oauth_flow$(DLSUFFIX) oauth_flow.o
+
 NO_INSTALLCHECK = 1
 
 TAP_TESTS = 1
@@ -33,8 +35,14 @@ top_builddir = ../../../..
 include $(top_builddir)/src/Makefile.global
 include $(top_srcdir)/contrib/contrib-global.mk
 
+all: oauth_flow$(DLSUFFIX)
+
+oauth_flow$(DLSUFFIX): oauth_flow.o oauth_test_common.o
+	$(CC) $(CFLAGS) $^ $(LDFLAGS) $(libpq_pgport_shlib) $(LDFLAGS_SL) -shared -o $@
+
 export PYTHON
 export with_libcurl
 export with_python
+export flow_module_path := $(abs_top_builddir)/$(subdir)/oauth_flow$(DLSUFFIX)
 
 endif
diff --git a/src/test/modules/oauth_validator/oauth_test_common.h b/src/test/modules/oauth_validator/oauth_test_common.h
new file mode 100644
index 00000000000..33e72e30440
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_test_common.h
@@ -0,0 +1,26 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_test_common.h
+ *	  Shared functionality for oauth_hook_client and oauth_flow
+ *
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef OAUTH_TEST_COMMON_H
+#define OAUTH_TEST_COMMON_H
+
+/*
+ * Only public headers can be here, since oauth_flow.c is trying to test only
+ * the public API.
+ */
+#include "libpq-fe.h"
+
+extern int	stress_async;		/* for oauth_hook_client */
+
+extern char *oauth_test_parse_argv(int argc, char *argv[], int for_plugin);
+extern int	oauth_test_authdata_hook(PGauthData type, PGconn *conn, void *data);
+extern int	oauth_test_start_flow(PGconn *conn, PGoauthBearerRequestV2 *request);
+
+#endif							/* OAUTH_TEST_COMMON_H */
diff --git a/src/test/modules/oauth_validator/oauth_flow.c b/src/test/modules/oauth_validator/oauth_flow.c
new file mode 100644
index 00000000000..8068a45ae29
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_flow.c
@@ -0,0 +1,69 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_flow.c
+ *	  Test plugin for clientside OAuth flows
+ *
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include <stdlib.h>
+#include <string.h>
+
+/* Since we want to test the public API, only include public headers here. */
+#include "libpq-fe.h"
+#include "libpq-oauth.h"
+#include "oauth_test_common.h"
+
+static void
+load_test_flags(void)
+{
+	int			argc;
+	char	  **argv;
+	char	   *env = getenv("OAUTH_TEST_FLAGS");
+	int			flag_count;
+	int			i;
+
+	if (!env || !env[0])
+	{
+		fprintf(stderr, "OAUTH_TEST_FLAGS must be set\n");
+		exit(1);
+	}
+
+	flag_count = 1;
+	for (char *c = env; *c; c++)
+	{
+		if (*c == '\x01')
+			flag_count++;
+	}
+
+	argc = flag_count + 1;
+	argv = malloc(sizeof(*argv) * (argc + 1));
+	if (!argv)
+	{
+		fprintf(stderr, "out of memory");
+		exit(1);
+	}
+
+	argv[0] = "[plugin test]";
+	for (i = 1; i < flag_count; i++)
+	{
+		argv[i] = env;
+
+		env = strchr(env, '\x01');
+		*env++ = '\0';
+	}
+	argv[flag_count] = env;
+	argv[argc] = NULL;
+
+	oauth_test_parse_argv(argc, argv, 1 /* plugin */ );
+}
+
+int
+pg_start_oauthbearer(PGconn *conn, PGoauthBearerRequestV2 *request)
+{
+	load_test_flags();
+
+	return oauth_test_start_flow(conn, request);
+}
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
index b1a3b014079..e0b022a268c 100644
--- a/src/test/modules/oauth_validator/oauth_hook_client.c
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -18,144 +18,18 @@
 
 #include <sys/socket.h>
 
-#include "getopt_long.h"
 #include "libpq-fe.h"
-#include "pqexpbuffer.h"
 
-static int	handle_auth_data(PGauthData type, PGconn *conn, void *data);
-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[])
-{
-	printf("usage: %s [flags] CONNINFO\n\n", argv[0]);
-
-	printf("recognized flags:\n");
-	printf("  -h, --help              show this message\n");
-	printf("  -v VERSION              select the hook API version (default 2)\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("  --expected-issuer ISS   fail if received issuer does not match ISS (v2 only)\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\n");
-	printf("  --hang-forever          don't ever return a token (combine with connect_timeout)\n");
-	printf("  --token TOKEN           use the provided TOKEN value\n");
-	printf("  --error ERRMSG          fail instead, with the given ERRMSG (v2 only)\n");
-	printf("  --stress-async          busy-loop on PQconnectPoll rather than polling\n");
-}
-
-/* --options */
-static bool no_hook = false;
-static bool hang_forever = false;
-static bool stress_async = false;
-static const char *expected_uri = NULL;
-static const char *expected_issuer = NULL;
-static const char *expected_scope = NULL;
-static const char *misbehave_mode = NULL;
-static char *token = NULL;
-static char *errmsg = NULL;
-static int	hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN_V2;
+#include "oauth_test_common.h"
 
 int
 main(int argc, char *argv[])
 {
-	static const struct option long_options[] = {
-		{"help", no_argument, NULL, 'h'},
-
-		{"expected-scope", required_argument, NULL, 1000},
-		{"expected-uri", required_argument, NULL, 1001},
-		{"no-hook", no_argument, NULL, 1002},
-		{"token", required_argument, NULL, 1003},
-		{"hang-forever", no_argument, NULL, 1004},
-		{"misbehave", required_argument, NULL, 1005},
-		{"stress-async", no_argument, NULL, 1006},
-		{"expected-issuer", required_argument, NULL, 1007},
-		{"error", required_argument, NULL, 1008},
-		{0}
-	};
-
-	const char *conninfo;
+	const char *conninfo = oauth_test_parse_argv(argc, argv, 0 /* hook */ );
 	PGconn	   *conn;
-	int			c;
-
-	while ((c = getopt_long(argc, argv, "hv:", long_options, NULL)) != -1)
-	{
-		switch (c)
-		{
-			case 'h':
-				usage(argv);
-				return 0;
-
-			case 'v':
-				if (strcmp(optarg, "1") == 0)
-					hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN;
-				else if (strcmp(optarg, "2") == 0)
-					hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN_V2;
-				else
-				{
-					usage(argv);
-					return 1;
-				}
-				break;
-
-			case 1000:			/* --expected-scope */
-				expected_scope = optarg;
-				break;
-
-			case 1001:			/* --expected-uri */
-				expected_uri = optarg;
-				break;
-
-			case 1002:			/* --no-hook */
-				no_hook = true;
-				break;
-
-			case 1003:			/* --token */
-				token = optarg;
-				break;
-
-			case 1004:			/* --hang-forever */
-				hang_forever = true;
-				break;
-
-			case 1005:			/* --misbehave */
-				misbehave_mode = optarg;
-				break;
-
-			case 1006:			/* --stress-async */
-				stress_async = true;
-				break;
-
-			case 1007:			/* --expected-issuer */
-				expected_issuer = optarg;
-				break;
-
-			case 1008:			/* --error */
-				errmsg = optarg;
-				break;
-
-			default:
-				usage(argv);
-				return 1;
-		}
-	}
-
-	if (argc != optind + 1)
-	{
-		usage(argv);
-		return 1;
-	}
-
-	conninfo = argv[optind];
 
 	/* Set up our OAuth hooks. */
-	PQsetAuthDataHook(handle_auth_data);
+	PQsetAuthDataHook(oauth_test_authdata_hook);
 
 	/* Connect. (All the actual work is in the hook.) */
 	if (stress_async)
@@ -193,190 +67,3 @@ main(int argc, char *argv[])
 	PQfinish(conn);
 	return 0;
 }
-
-/*
- * PQauthDataHook implementation. Replaces the default client flow by handling
- * PQAUTHDATA_OAUTH_BEARER_TOKEN[_V2].
- */
-static int
-handle_auth_data(PGauthData type, PGconn *conn, void *data)
-{
-	PGoauthBearerRequest *req;
-	PGoauthBearerRequestV2 *req2 = NULL;
-
-	Assert(hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN ||
-		   hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN_V2);
-
-	if (no_hook || type != hook_version)
-		return 0;
-
-	req = data;
-	if (type == PQAUTHDATA_OAUTH_BEARER_TOKEN_V2)
-		req2 = data;
-
-	if (hang_forever)
-	{
-		/* Start asynchronous processing. */
-		req->async = async_cb;
-		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)
-		{
-			fprintf(stderr, "expected URI \"%s\", got NULL\n", expected_uri);
-			return -1;
-		}
-
-		if (strcmp(expected_uri, req->openid_configuration) != 0)
-		{
-			fprintf(stderr, "expected URI \"%s\", got \"%s\"\n", expected_uri, req->openid_configuration);
-			return -1;
-		}
-	}
-
-	if (expected_scope)
-	{
-		if (!req->scope)
-		{
-			fprintf(stderr, "expected scope \"%s\", got NULL\n", expected_scope);
-			return -1;
-		}
-
-		if (strcmp(expected_scope, req->scope) != 0)
-		{
-			fprintf(stderr, "expected scope \"%s\", got \"%s\"\n", expected_scope, req->scope);
-			return -1;
-		}
-	}
-
-	if (expected_issuer)
-	{
-		if (!req2)
-		{
-			fprintf(stderr, "--expected-issuer cannot be combined with -v1\n");
-			return -1;
-		}
-
-		if (!req2->issuer)
-		{
-			fprintf(stderr, "expected issuer \"%s\", got NULL\n", expected_issuer);
-			return -1;
-		}
-
-		if (strcmp(expected_issuer, req2->issuer) != 0)
-		{
-			fprintf(stderr, "expected issuer \"%s\", got \"%s\"\n", expected_issuer, req2->issuer);
-			return -1;
-		}
-	}
-
-	if (errmsg)
-	{
-		if (token)
-		{
-			fprintf(stderr, "--error cannot be combined with --token\n");
-			return -1;
-		}
-		else if (!req2)
-		{
-			fprintf(stderr, "--error cannot be combined with -v1\n");
-			return -1;
-		}
-
-		appendPQExpBufferStr(req2->error, errmsg);
-		return -1;
-	}
-
-	req->token = token;
-	return 1;
-}
-
-static PostgresPollingStatusType
-async_cb(PGconn *conn, PGoauthBearerRequest *req, pgsocket *altsock)
-{
-	if (hang_forever)
-	{
-		/*
-		 * This code tests that nothing is interfering with libpq's handling
-		 * of connect_timeout.
-		 */
-		static pgsocket sock = PGINVALID_SOCKET;
-
-		if (sock == PGINVALID_SOCKET)
-		{
-			/* First call. Create an unbound socket to wait on. */
-#ifdef WIN32
-			WSADATA		wsaData;
-			int			err;
-
-			err = WSAStartup(MAKEWORD(2, 2), &wsaData);
-			if (err)
-			{
-				perror("WSAStartup failed");
-				return PGRES_POLLING_FAILED;
-			}
-#endif
-			sock = socket(AF_INET, SOCK_DGRAM, 0);
-			if (sock == PGINVALID_SOCKET)
-			{
-				perror("failed to create datagram socket");
-				return PGRES_POLLING_FAILED;
-			}
-		}
-
-		/* Make libpq wait on the (unreadable) socket. */
-		*altsock = sock;
-		return PGRES_POLLING_READING;
-	}
-
-	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". */
-		if (errmsg)
-		{
-			PGoauthBearerRequestV2 *req2;
-
-			if (hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN)
-			{
-				fprintf(stderr, "--error cannot be combined with -v1\n");
-				exit(1);
-			}
-
-			req2 = (PGoauthBearerRequestV2 *) req;
-			appendPQExpBufferStr(req2->error, errmsg);
-		}
-
-		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);
-	}
-}
diff --git a/src/test/modules/oauth_validator/oauth_test_common.c b/src/test/modules/oauth_validator/oauth_test_common.c
new file mode 100644
index 00000000000..f2a5b180a65
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_test_common.c
@@ -0,0 +1,374 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_test_common.c
+ *	  Shared functionality for oauth_hook_client and oauth_flow
+ *
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <sys/socket.h>
+
+#include "getopt_long.h"
+#include "libpq-fe.h"
+#include "pqexpbuffer.h"
+
+#include "oauth_test_common.h"
+
+static PostgresPollingStatusType async_cb(PGconn *conn,
+										  PGoauthBearerRequest *req,
+										  pgsocket *altsock);
+static PostgresPollingStatusType misbehave_cb(PGconn *conn,
+											  PGoauthBearerRequest *req,
+											  pgsocket *altsock);
+
+/* --options */
+static bool no_hook = false;
+static bool hang_forever = false;
+static const char *expected_uri = NULL;
+static const char *expected_issuer = NULL;
+static const char *expected_scope = NULL;
+static const char *misbehave_mode = NULL;
+static char *token = NULL;
+static char *errmsg = NULL;
+static int	hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN_V2;
+
+/*
+ * XXX: stress_async is exported for the benefit of oauth_hook_client. Since
+ * we only use public headers (libpq-fe.h) for oauth_flow, it needs to be an int
+ * rather than a bool.
+ */
+int			stress_async = false;
+
+static void
+usage(char *argv[])
+{
+	printf("usage: %s [flags] CONNINFO\n\n", argv[0]);
+
+	printf("recognized flags:\n");
+	printf("  -h, --help              show this message\n");
+	printf("  -v VERSION              select the hook API version (default 2)\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("  --expected-issuer ISS   fail if received issuer does not match ISS (v2 only)\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\n");
+	printf("  --hang-forever          don't ever return a token (combine with connect_timeout)\n");
+	printf("  --token TOKEN           use the provided TOKEN value\n");
+	printf("  --error ERRMSG          fail instead, with the given ERRMSG (v2 only)\n");
+	printf("  --stress-async          busy-loop on PQconnectPoll rather than polling\n");
+}
+
+char *
+oauth_test_parse_argv(int argc, char *argv[], int for_plugin)
+{
+	static const struct option long_options[] = {
+		{"help", no_argument, NULL, 'h'},
+
+		{"expected-scope", required_argument, NULL, 1000},
+		{"expected-uri", required_argument, NULL, 1001},
+		{"no-hook", no_argument, NULL, 1002},
+		{"token", required_argument, NULL, 1003},
+		{"hang-forever", no_argument, NULL, 1004},
+		{"misbehave", required_argument, NULL, 1005},
+		{"stress-async", no_argument, NULL, 1006},
+		{"expected-issuer", required_argument, NULL, 1007},
+		{"error", required_argument, NULL, 1008},
+		{0}
+	};
+
+	int			c;
+
+	if (for_plugin)
+	{
+		/* The "real" argv has already been parsed. Reset optind. */
+		optind = 1;
+	}
+
+	while ((c = getopt_long(argc, argv, "hv:", long_options, NULL)) != -1)
+	{
+		switch (c)
+		{
+			case 'h':
+				usage(argv);
+				exit(0);
+
+			case 'v':
+				if (strcmp(optarg, "1") == 0)
+					hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN;
+				else if (strcmp(optarg, "2") == 0)
+					hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN_V2;
+				else
+				{
+					usage(argv);
+					exit(1);
+				}
+				break;
+
+			case 1000:			/* --expected-scope */
+				expected_scope = optarg;
+				break;
+
+			case 1001:			/* --expected-uri */
+				expected_uri = optarg;
+				break;
+
+			case 1002:			/* --no-hook */
+				no_hook = true;
+				break;
+
+			case 1003:			/* --token */
+				token = optarg;
+				break;
+
+			case 1004:			/* --hang-forever */
+				hang_forever = true;
+				break;
+
+			case 1005:			/* --misbehave */
+				misbehave_mode = optarg;
+				break;
+
+			case 1006:			/* --stress-async */
+				stress_async = true;
+				break;
+
+			case 1007:			/* --expected-issuer */
+				expected_issuer = optarg;
+				break;
+
+			case 1008:			/* --error */
+				errmsg = optarg;
+				break;
+
+			default:
+				usage(argv);
+				exit(1);
+		}
+	}
+
+	if (argc != (for_plugin ? optind : optind + 1))
+	{
+		usage(argv);
+		exit(1);
+	}
+
+	return argv[optind];
+}
+
+/*
+ * PQauthDataHook implementation. Replaces the default client flow by handling
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN[_V2].
+ */
+int
+oauth_test_authdata_hook(PGauthData type, PGconn *conn, void *data)
+{
+	PGoauthBearerRequest *req;
+	PGoauthBearerRequestV2 *req2 = NULL;
+
+	Assert(hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN ||
+		   hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN_V2);
+
+	if (no_hook || type != hook_version)
+		return 0;
+
+	req = data;
+	if (type == PQAUTHDATA_OAUTH_BEARER_TOKEN_V2)
+		req2 = data;
+
+	if (hang_forever)
+	{
+		/* Start asynchronous processing. */
+		req->async = async_cb;
+		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)
+		{
+			fprintf(stderr, "expected URI \"%s\", got NULL\n", expected_uri);
+			return -1;
+		}
+
+		if (strcmp(expected_uri, req->openid_configuration) != 0)
+		{
+			fprintf(stderr, "expected URI \"%s\", got \"%s\"\n", expected_uri, req->openid_configuration);
+			return -1;
+		}
+	}
+
+	if (expected_scope)
+	{
+		if (!req->scope)
+		{
+			fprintf(stderr, "expected scope \"%s\", got NULL\n", expected_scope);
+			return -1;
+		}
+
+		if (strcmp(expected_scope, req->scope) != 0)
+		{
+			fprintf(stderr, "expected scope \"%s\", got \"%s\"\n", expected_scope, req->scope);
+			return -1;
+		}
+	}
+
+	if (expected_issuer)
+	{
+		if (!req2)
+		{
+			fprintf(stderr, "--expected-issuer cannot be combined with -v1\n");
+			return -1;
+		}
+
+		if (!req2->issuer)
+		{
+			fprintf(stderr, "expected issuer \"%s\", got NULL\n", expected_issuer);
+			return -1;
+		}
+
+		if (strcmp(expected_issuer, req2->issuer) != 0)
+		{
+			fprintf(stderr, "expected issuer \"%s\", got \"%s\"\n", expected_issuer, req2->issuer);
+			return -1;
+		}
+	}
+
+	if (errmsg)
+	{
+		if (token)
+		{
+			fprintf(stderr, "--error cannot be combined with --token\n");
+			return -1;
+		}
+		else if (!req2)
+		{
+			fprintf(stderr, "--error cannot be combined with -v1\n");
+			return -1;
+		}
+
+		appendPQExpBufferStr(req2->error, errmsg);
+		return -1;
+	}
+
+	req->token = token;
+	return 1;
+}
+
+/*
+ * Sets up a request for a plugin module (pg_start_oauthbearer()) rather than
+ * using the hook.
+ */
+int
+oauth_test_start_flow(PGconn *conn, PGoauthBearerRequestV2 *request)
+{
+	int			ret;
+
+	/*
+	 * We can still defer to the hook above to avoid copying code; we just
+	 * have to translate the return value.
+	 */
+	ret = oauth_test_authdata_hook(PQAUTHDATA_OAUTH_BEARER_TOKEN_V2, conn,
+								   request);
+
+	if (ret == 0)
+	{
+		/* This is a bug in the test. */
+		fprintf(stderr, "plugin tests cannot make use of -v1 or --no-hook\n");
+		exit(1);
+	}
+
+	return (ret == 1) ? 0 : -1;
+}
+
+static PostgresPollingStatusType
+async_cb(PGconn *conn, PGoauthBearerRequest *req, pgsocket *altsock)
+{
+	if (hang_forever)
+	{
+		/*
+		 * This code tests that nothing is interfering with libpq's handling
+		 * of connect_timeout.
+		 */
+		static pgsocket sock = PGINVALID_SOCKET;
+
+		if (sock == PGINVALID_SOCKET)
+		{
+			/* First call. Create an unbound socket to wait on. */
+#ifdef WIN32
+			WSADATA		wsaData;
+			int			err;
+
+			err = WSAStartup(MAKEWORD(2, 2), &wsaData);
+			if (err)
+			{
+				perror("WSAStartup failed");
+				return PGRES_POLLING_FAILED;
+			}
+#endif
+			sock = socket(AF_INET, SOCK_DGRAM, 0);
+			if (sock == PGINVALID_SOCKET)
+			{
+				perror("failed to create datagram socket");
+				return PGRES_POLLING_FAILED;
+			}
+		}
+
+		/* Make libpq wait on the (unreadable) socket. */
+		*altsock = sock;
+		return PGRES_POLLING_READING;
+	}
+
+	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". */
+		if (errmsg)
+		{
+			PGoauthBearerRequestV2 *req2;
+
+			if (hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN)
+			{
+				fprintf(stderr, "--error cannot be combined with -v1\n");
+				exit(1);
+			}
+
+			req2 = (PGoauthBearerRequestV2 *) req;
+			appendPQExpBufferStr(req2->error, errmsg);
+		}
+
+		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);
+	}
+}
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
index 0cee72ef58e..d8ecebdfd1d 100644
--- a/src/test/modules/oauth_validator/t/002_client.pl
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -1,6 +1,6 @@
 #
 # Exercises the API for custom OAuth client flows, using the oauth_hook_client
-# test driver.
+# test driver and the oauth_flow custom plugin.
 #
 # Copyright (c) 2021-2025, PostgreSQL Global Development Group
 #
@@ -20,6 +20,10 @@ if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
 	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
 }
 
+my $plugin_supported = (
+		 check_pg_config("#define HAVE_SYS_EVENT_H 1")
+	  or check_pg_config("#define HAVE_SYS_EPOLL_H 1"));
+
 #
 # Cluster Setup
 #
@@ -72,6 +76,8 @@ sub test
 		$flags = $params{flags};
 	}
 
+	# First run the oauth_hook_client, which uses PQauthDataHook to insert a new
+	# OAuth flow.
 	my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr);
 	note "running '" . join("' '", @cmd) . "'";
 
@@ -103,6 +109,37 @@ sub test
 		$node->log_check("$test_name: log matches",
 			$log_start, log_like => $params{log_like});
 	}
+
+  SKIP:
+	{
+		last SKIP if $params{hook_only};
+		skip "OAuth modules are not supported on this platform"
+		  unless $plugin_supported;
+
+		# Run the same test with psql itself, loading the oauth_flow.so module.
+		local $ENV{PGOAUTHMODULE} = $ENV{flow_module_path};
+
+		# Flags are passed to the module via OAUTH_TEST_FLAGS, with 0x01 as a
+		# separator.
+		local $ENV{OAUTH_TEST_FLAGS} = join("\x01", @{$flags});
+
+		if ($params{expect_success})
+		{
+			$node->connect_ok(
+				$common_connstr,
+				"[plugin flow] $test_name",
+				expected_stderr => $params{expected_stderr},
+				log_like => $params{log_like});
+		}
+		else
+		{
+			$node->connect_fails(
+				$common_connstr,
+				"[plugin flow] $test_name",
+				expected_stderr => $params{expected_stderr},
+				log_like => $params{log_like});
+		}
+	}
 }
 
 test(
@@ -119,6 +156,7 @@ test(
 # Make sure the v1 hook continues to work. */
 test(
 	"v1 synchronous hook can provide a token",
+	hook_only => 1,    # plugins don't support API v1
 	flags => [
 		"-v1",
 		"--token" => "my-token-v1",
@@ -133,6 +171,7 @@ if ($ENV{with_libcurl} ne 'yes')
 	# libpq should help users out if no OAuth support is built in.
 	test(
 		"fails without custom hook installed",
+		hook_only => 1,    # plugins can't use --no-hook
 		flags => ["--no-hook"],
 		expected_stderr =>
 		  qr/no OAuth flows are available \(try installing the libpq-oauth package\)/
-- 
2.34.1

#2Chao Li
li.evan.chao@gmail.com
In reply to: Jacob Champion (#1)
Re: [oauth] Stabilize the libpq-oauth ABI (and allow alternative implementations?)

On Dec 10, 2025, at 05:00, Jacob Champion <jacob.champion@enterprisedb.com> wrote:

Hi everybody,

We introduced the libpq-oauth module late in the cycle for PG18, and
to put it mildly, its interface isn't great. The original
implementation depended on libpq internals, and we had to make sure
that we didn't start crashing during major or minor version upgrades.
So there were a bunch of compromises made to keep things safe,
including the restriction that the module name has to change every
major release.

Separately, but closely related: PG18's OAuth support allows you to
customize the client flow via a libpq hook function. Third-party
applications can make use of that, but our own utilities can still
only use the builtin device flow. That's annoying.

I've been working to replace the internal ABI with a stable one,
hopefully to solve both problems at the same time. A dlopen() is a
pretty clear seam for other people to use to modify and extend.
Unfortunately my first attempt (not pictured) ended up in a rabbit
hole, because I tried to tackle the third-party use case first. My
second attempt, attached, focuses on the ABI stabilization instead,
which I think is more likely to succeed.

(This took enough thinking that I'm really glad we didn't try this for
PG18. Thanks for letting me take on some technical debt for a bit.)

= Design =

Here's the train of thought behind the core changes (which are in patch 0004):

The builtin-flow code in fe-auth-oauth.c is similar to the custom-flow
code, but it's just ever-so-slightly different. I'd like to unify the
two, so I want libpq-oauth to make use of the public
PGoauthBearerRequest API, and that means that almost all of the
injections made in the PG18 ABI need to be replaced.

Most of those injections are simply subsumed by the hook API
(hooray!). A couple of others can be replaced by PQconninfo(). Four
are left over:
- pgthreadlock_t
- libpq_gettext
- conn->errorMessage
- conn->oauth_issuer_id

I think we should keep injecting libpq_gettext; no third-party
implementations would be able to use that. And application hooks are
presumably capable of figuring out top-level locking already, since
the application has to have called PQregisterThreadLock() if it needed
to coordinate with libpq.

That leaves error messages and the issuer identifier. I think both
would be useful for hooks to have, too, so I'd like to add them to
PQAUTHDATA_OAUTH_BEARER_TOKEN.

= PQAUTHDATA_OAUTH_BEARER_TOKEN, version 2 =

My original plan for authdata extensions was to add new members to the
end of the structs that libpq passes into the hook. Applications would
gate on a feature macro during compilation to see whether they could
use the new members. That should work fine for an application hook;
you're not allowed to downgrade libpq past the version that your
applications are compiled against, lest you lose symbols (or other
feature-flag functionality) you're relying on.

Plugins, unfortunately, can't rely on the feature macro. As we found
out during the libpq-oauth split [1], we have to handle a long-running
application with an old libpq that loads an upgraded libpq-oauth, even
if the OS package dependencies suggest otherwise. (A plugin
architecture flips the direction of the runtime dependency arrow.)

There are a couple ways we could handle this. For this draft, I've
implemented what I think is a middle ground between verbosity and
type-safety: introduce a new V2 struct that "inherits" the V1 struct
and can be down-cast in the callbacks, kinda similar to our Node
hierarchy. We could go even more verbose, and duplicate the entire
PGoauthBearerToken struct -- matching the callback parameter types for
maximum safety -- but I'm not convinced that this would be a good use
of maintenance effort. The ability to cast between the two means we
can share v1 and v2 implementations in our tests.

We could also just add the new members at the end, say that you're
only allowed to use them if the V2 hook type is in use, and scribble
on them in V1 hooks to try to get misbehaving implementations to crash
outright. This arguably has the same amount of type-safety as the
downcast, and the resulting code looks nicer. (The libcurl API we use
does something similar with curl_version_info().) But it is definitely
more "magic".

Also of note: this adds a PQExpBuffer to libpq-fe.h. Technically, that
type is "internal". But... is it really, though? It doesn't seem
possible for us to make incompatible changes there without crashing
earlier psqls, in which case I would like to make use of it too. Maybe
this deserves its own minithread.

Okay, on to the full patchset.

= Roadmap: Prep =

The first few patches are bugfixes I intend to backpatch to 18.

- 0001: I stomped on the SOCKTYPE name in libpq-fe.h, but that's not
in our namespace and it's conceivable that it might collide with
someone else. (It collided with my own test code during my work on
this.)
- 0002 fixes a copy-paste bug in meson.build, which luckily hadn't
caused problems yet.
- 0003 ports Tom's debug2 fix for Test::Cluster::connect_fails() over
to 002_client.pl. (Currently, log checks in this test aren't made
after connection failures, but I don't really want to chase that down
later after I've forgotten about it.)

= Roadmap: Implementation =

Next three patches are the core implementation, which stabilizes the
ABI for libpq-oauth. I feel fairly confident that this, or something
close to it, could land in PG19.

- 0004 introduces the new PQAUTHDATA_OAUTH_BEARER_TOKEN_V2 API.

As described above.

- 0005 makes use of the new API in libpq-oauth.

This removes more code than it introduces, which is exciting.

Now we can rename libpq-oauth-19.so to just libpq-oauth.so, since we
no longer depend on anything that could change between major versions.
(We still need lock and locale support from libpq, as mentioned above,
so they continue to be decoupled via injection at init time.)

Some of the code in this patch overlaps with the translation fixes
thread [2], which I need to get to first. I'm hoping some additional
simplifications can be made after those localization bugs are fixed.

I think I'd also like to get the threadlock into the module API (but
not the hook API). A third-party flow might need to perform its own
one-time initialization, and if it relies on an old version of Curl
(or worse, Kerberos [3]), it'll need to use the same lock that the
top-level application has registered for libpq. So I imagine I'll need
to break out an initialization function here. Alternatively, I could
introduce an API into libpq to retrieve the threadlock function in
use?

- 0006 removes a potential ABI-compatibility pitfall for future devs.

libpq-oauth needs to use the shared-library variant of libpgcommon,
but it can no longer assume that the encoding support exported by
libpq is compatible. So it must not accidentally link against those
functions (see [4]). I don't imagine anyone will try adding code that
does this in practice; we're pretty UTF8-centric in OAuth. But just to
be safe, define USE_PRIVATE_ENCODING_FUNCS so that anyone who tries
will fail the build.

= Roadmap: Plugins? =

So now we have a stable ABI, which technically means that any
enterprising developer who wants to play games with LD_LIBRARY_PATH
could implement their own version of an OAuth flow, and have our
utilities make use of it into the future.

That brings us to patch 0007, which experimentally promotes the stable
API to a public header, and introduces a really janky environment
variable so that people don't have to play games. It will be obvious
from the code that this is not well-baked yet.

I hope the ability to dlopen() a custom flow can make it for 19; I
just don't know that this envvar approach is any good. The ideal
situation, IMO, is for a flow to be selected in the connection string.
But we have to lock that down, similarly to how we protect
local_preload_libraries, to prevent horrible exploits. At which point
we'll have essentially designed a generic libpq plugin system. Not
necessarily a terrible thing, but I don't think I have time to take it
on for PG19.

WDYT?
--Jacob

[1] /messages/by-id/aAkJnDQq3mOUvmQV@msg.df7cb.de
[2] /messages/by-id/TY4PR01MB1690746DB91991D1E9A47F57E94CDA@TY4PR01MB16907.jpnprd01.prod.outlook.com
[3] /messages/by-id/aSSp03wmNMngi/Oe@ubby
[4] https://postgr.es/c/b6c7cfac8
<0001-libpq-fe.h-Don-t-claim-SOCKTYPE-in-the-global-namesp.patch><0002-libpq-oauth-use-correct-c_args-in-meson.build.patch><0003-oauth_validator-Avoid-races-in-log_check.patch><0004-libpq-Introduce-PQAUTHDATA_OAUTH_BEARER_TOKEN_V2.patch><0005-libpq-oauth-Use-the-PGoauthBearerRequestV2-API.patch><0006-libpq-oauth-Never-link-against-libpq-s-encoding-func.patch><0007-WIP-Introduce-third-party-OAuth-flow-plugins.patch>

Hi Jacob,

This is a solid patch set. Only a few small comments:

1 - 0001
```
 /* for PGoauthBearerRequest.async() */
 #ifdef _WIN32
-#define SOCKTYPE uintptr_t		/* avoids depending on winsock2.h for SOCKET */
+#define PQ_SOCKTYPE uintptr_t	/* avoids depending on winsock2.h for SOCKET */
 #else
-#define SOCKTYPE int
+#define PQ_SOCKTYPE int
 #endif
```

The commit message has explained why SOCKTYPE is temporary and the reason why adding prefix “PG_” is to avoid collisions. But I don’t think code readers will always read commit messages, given the macro is a local and temporary, why adding a prefix starting with a underscore, like “_PQ_SOCKTYPE”, which explicitly indicates the macro is kinda private.

===
0002 & 0003 Looks good.
===

2 - 0004
```
+ * Helper for handling user flow failures. If the implementation put anything
+ * into request->error, it's added to conn->errorMessage here.
```

Typo: put -> puts

3 - 0004
```
+# Make sure the v1 hook continues to work. */
+test(
```

“*/“ in the end of the comment line seems a typo.

4 - 0005
```
+	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token
+									 * (prefer v2, below, instead) */
```
"(prefer v2, below, instead)" looks confusing to me, though I can understand what it means. Maybe make it clearer, like “(v2 is preferred; see below)"

===
0006 Looks good.
===

===
Not reviewing 0007 as it marks with WIP.
===

Best regards,
--
Chao Li (Evan)
HighGo Software Co., Ltd.
https://www.highgo.com/

#3Jacob Champion
jacob.champion@enterprisedb.com
In reply to: Chao Li (#2)
8 attachment(s)
Re: [oauth] Stabilize the libpq-oauth ABI (and allow alternative implementations?)

On Thu, Dec 11, 2025 at 11:43 PM Chao Li <li.evan.chao@gmail.com> wrote:

This is a solid patch set. Only a few small comments:

Thanks for the review!

The commit message has explained why SOCKTYPE is temporary and the reason why adding prefix “PG_” is to avoid collisions. But I don’t think code readers will always read commit messages, given the macro is a local and temporary, why adding a prefix starting with a underscore, like “_PQ_SOCKTYPE”, which explicitly indicates the macro is kinda private.

_PQ_SOCKTYPE is reserved (starts with _P), but I could add more
explanatory comments if you think that'd be useful. See v2-0001, which
now includes an explanation of the signature in the documentation.

The hard part is that I don't want to require all Windows clients of
libpq-fe.h to have to depend on Winsock; that's the only reason for
this oddity. Otherwise I'd declare PGsocket as the public version of
our internal pgsocket and call it a day.

+ * Helper for handling user flow failures. If the implementation put anything
+ * into request->error, it's added to conn->errorMessage here.
```

Typo: put -> puts

Past tense was my intent, but I've reworded to avoid any garden paths:
"If anything was put into request->error, it's added to
conn->errorMessage here."

“*/“ in the end of the comment line seems a typo.

Thanks, no idea why I did that.

"(prefer v2, below, instead)" looks confusing to me, though I can understand what it means. Maybe make it clearer, like “(v2 is preferred; see below)"

Done.

--

v2 makes these changes and rebases over latest HEAD. I'll plan to get
0001-3 in this week; probably tomorrow.

Open questions remain:
1) 0004: Any objections to putting PQExpBuffer into libpq-fe.h?
2) 0004: Thoughts on the v2 inheritance struct style as opposed to
relying on implementations to double-check the struct length?
3) 0005: Should I add the thread lock to an init() API, or expose a
new PQgetThreadLock() that other code can use?
4) 0007: [all of it]

My personal thoughts on these:
1) it's fine
2) it's a coin flip for me; inheritance is ugly, length magic is scary
3) I like the idea of PQgetThreadLock() so that we don't have to
inject it everywhere it could possibly be needed

Thanks,
--Jacob

Attachments:

since-v1.diff.txttext/plain; charset=US-ASCII; name=since-v1.diff.txtDownload
1:  3b27f54b4dc ! 1:  8312bf30726 libpq-fe.h: Don't claim SOCKTYPE in the global namespace
    @@ Commit message
         doesn't seem too far-fetched, given its proximity to existing POSIX
         macro names.
     
    -    Add a PQ_ prefix to avoid collisions, and backpatch.
    +    Add a PQ_ prefix to avoid collisions, update and improve the surrounding
    +    documentation, and backpatch.
     
    +    Reviewed-by: Chao Li <li.evan.chao@gmail.com>
    +    Discussion: https://postgr.es/m/CAOYmi%2BmrGg%2Bn_X2MOLgeWcj3v_M00gR8uz_D7mM8z%3DdX1JYVbg%40mail.gmail.com
         Backpatch-through: 18
     
      ## doc/src/sgml/libpq.sgml ##
     @@ doc/src/sgml/libpq.sgml: typedef struct PGoauthBearerRequest
    -     /* Callback implementing a custom asynchronous OAuth flow. */
    + 
    +     /* Hook outputs */
    + 
    +-    /* Callback implementing a custom asynchronous OAuth flow. */
    ++    /*
    ++     * Callback implementing a custom asynchronous OAuth flow. The signature is
    ++     * platform-dependent: PQ_SOCKTYPE is SOCKET on Windows, and int everywhere
    ++     * else.
    ++     */
          PostgresPollingStatusType (*async) (PGconn *conn,
                                              struct PGoauthBearerRequest *request,
     -                                        SOCKTYPE *altsock);
    @@ doc/src/sgml/libpq.sgml: typedef struct PGoauthBearerRequest
      
          /* Callback to clean up custom allocations. */
          void        (*cleanup) (PGconn *conn, struct PGoauthBearerRequest *request);
    +@@ doc/src/sgml/libpq.sgml: typedef struct PGoauthBearerRequest
    +          hook. When the callback cannot make further progress without blocking,
    +          it should return either <symbol>PGRES_POLLING_READING</symbol> or
    +          <symbol>PGRES_POLLING_WRITING</symbol> after setting
    +-         <literal>*pgsocket</literal> to the file descriptor that will be marked
    ++         <literal>*altsock</literal> to the file descriptor that will be marked
    +          ready to read/write when progress can be made again. (This descriptor
    +          is then provided to the top-level polling loop via
    +          <function>PQsocket()</function>.) Return <symbol>PGRES_POLLING_OK</symbol>
     
      ## src/interfaces/libpq/libpq-fe.h ##
     @@ src/interfaces/libpq/libpq-fe.h: typedef struct _PGpromptOAuthDevice
    + 	int			expires_in;		/* seconds until user code expires */
    + } PGpromptOAuthDevice;
      
    - /* for PGoauthBearerRequest.async() */
    +-/* for PGoauthBearerRequest.async() */
    ++/*
    ++ * For PGoauthBearerRequest.async(). This macro just allows clients to avoid
    ++ * depending on libpq-int.h or Winsock for the "socket" type; it's undefined
    ++ * immediately below.
    ++ */
      #ifdef _WIN32
     -#define SOCKTYPE uintptr_t		/* avoids depending on winsock2.h for SOCKET */
     +#define PQ_SOCKTYPE uintptr_t	/* avoids depending on winsock2.h for SOCKET */
    @@ src/interfaces/libpq/libpq-fe.h: typedef struct _PGpromptOAuthDevice
      
      typedef struct PGoauthBearerRequest
     @@ src/interfaces/libpq/libpq-fe.h: typedef struct PGoauthBearerRequest
    + 	 * blocking during the original call to the PQAUTHDATA_OAUTH_BEARER_TOKEN
    + 	 * hook, it may be returned directly, but one of request->async or
    + 	 * request->token must be set by the hook.
    ++	 *
    ++	 * The (PQ_SOCKTYPE *) in the signature is a placeholder for the platform's
    ++	 * native socket type: (SOCKET *) on Windows, and (int *) everywhere else.
      	 */
      	PostgresPollingStatusType (*async) (PGconn *conn,
      										struct PGoauthBearerRequest *request,
2:  3b5690a49d7 ! 2:  899597d5777 libpq-oauth: use correct c_args in meson.build
    @@ Commit message
         libpq_so_c_args, rather than libpq_oauth_so_c_args. (At the moment, the
         two lists are identical, but that won't be true forever.)
     
    +    Reviewed-by: Chao Li <li.evan.chao@gmail.com>
    +    Discussion: https://postgr.es/m/CAOYmi%2BmrGg%2Bn_X2MOLgeWcj3v_M00gR8uz_D7mM8z%3DdX1JYVbg%40mail.gmail.com
         Backpatch-through: 18
     
      ## src/interfaces/libpq-oauth/meson.build ##
3:  4933e9b53fb ! 3:  953c743f692 oauth_validator: Avoid races in log_check()
    @@ Commit message
     
         Commit e0f373ee4 fixed up races in Cluster::connect_fails when using
         log_like. t/002_client.pl didn't get the memo, though, because it
    -    doesn't use Test::Cluster to perform its custom hook tests. Introduce
    -    the fix, based on debug2 logging, to its use of log_check() as well, and
    -    move the logic into the test() helper so that any additions don't need
    -    to continually duplicate it.
    +    doesn't use Test::Cluster to perform its custom hook tests. (This is
    +    probably not an issue at the moment, since the log check is only done
    +    after authentication success and not failure, but there's no reason to
    +    wait for someone to hit it.)
     
    +    Introduce the fix, based on debug2 logging, to its use of log_check() as
    +    well, and move the logic into the test() helper so that any additions
    +    don't need to continually duplicate it.
    +
    +    Reviewed-by: Chao Li <li.evan.chao@gmail.com>
    +    Discussion: https://postgr.es/m/CAOYmi%2BmrGg%2Bn_X2MOLgeWcj3v_M00gR8uz_D7mM8z%3DdX1JYVbg%40mail.gmail.com
         Backpatch-through: 18
     
      ## src/test/modules/oauth_validator/t/002_client.pl ##
4:  b2cece52ba6 ! 4:  3c047e6cf24 libpq: Introduce PQAUTHDATA_OAUTH_BEARER_TOKEN_V2
    @@ Commit message
         TODO: Could we just add to the end of PGoauthBearerRequest, and tell
               users not to use the additional fields if they have version 1?
     
    +    Reviewed-by: Chao Li <li.evan.chao@gmail.com>
    +    Discussion: https://postgr.es/m/CAOYmi%2BmrGg%2Bn_X2MOLgeWcj3v_M00gR8uz_D7mM8z%3DdX1JYVbg%40mail.gmail.com
    +
      ## doc/src/sgml/libpq.sgml ##
     @@ doc/src/sgml/libpq.sgml: PQauthDataHook_type PQgetAuthDataHook(void);
              <indexterm><primary>PQAUTHDATA_PROMPT_OAUTH_DEVICE</primary></indexterm>
    @@ src/interfaces/libpq/libpq-fe.h: typedef enum
      									 * URL */
     -	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token */
     +	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token
    -+									 * (prefer v2, below, instead) */
    ++									 * (v2 is preferred; see below) */
     +	PQAUTHDATA_OAUTH_BEARER_TOKEN_V2,	/* newest API for OAuth Bearer tokens */
      } PGauthData;
      
    @@ src/interfaces/libpq/fe-auth-oauth.c: cleanup:
      }
      
     +/*
    -+ * Helper for handling user flow failures. If the implementation put anything
    -+ * into request->error, it's added to conn->errorMessage here.
    ++ * Helper for handling flow failures. If anything was put into request->error,
    ++ * it's added to conn->errorMessage here.
     + */
     +static void
     +report_user_flow_error(PGconn *conn, const PGoauthBearerRequestV2 *request)
    @@ src/test/modules/oauth_validator/t/002_client.pl: test(
     +	expect_success => 1,
      	log_like => [qr/oauth_validator: token="my-token", role="$user"/]);
      
    -+# Make sure the v1 hook continues to work. */
    ++# Make sure the v1 hook continues to work.
     +test(
     +	"v1 synchronous hook can provide a token",
     +	flags => [
5:  c012bc715e1 ! 5:  e655d4ee091 libpq-oauth: Use the PGoauthBearerRequestV2 API
    @@ Commit message
               to the private NLS initialization. Should we expose the lock to
               that function as well?
     
    +    Reviewed-by: Chao Li <li.evan.chao@gmail.com>
    +    Discussion: https://postgr.es/m/CAOYmi%2BmrGg%2Bn_X2MOLgeWcj3v_M00gR8uz_D7mM8z%3DdX1JYVbg%40mail.gmail.com
    +
      ## src/interfaces/libpq-oauth/README ##
     @@ src/interfaces/libpq-oauth/README: results in a failed connection.
      
    @@ src/interfaces/libpq-oauth/oauth-curl.c: free_async_ctx(PGconn *conn, struct asy
     +	 * also the documentation for struct async_ctx.
     +	 */
     +	if (actx->errctx)
    -+		appendPQExpBuffer(errbuf, "%s: ", libpq_gettext(actx->errctx));
    ++		appendPQExpBuffer(errbuf, "%s: ", actx->errctx);
     +
     +	if (PQExpBufferDataBroken(actx->errbuf))
     +		appendPQExpBufferStr(errbuf, libpq_gettext("out of memory"));
    @@ src/interfaces/libpq-oauth/oauth-curl.c: pg_fe_run_oauth_flow_impl(PGconn *conn)
     @@ src/interfaces/libpq-oauth/oauth-curl.c: pg_fe_run_oauth_flow_impl(PGconn *conn)
      		{
      			case OAUTH_STEP_INIT:
    - 				actx->errctx = "failed to fetch OpenID discovery document";
    + 				actx->errctx = libpq_gettext("failed to fetch OpenID discovery document");
     -				if (!start_discovery(actx, conn_oauth_discovery_uri(conn)))
     +				if (!start_discovery(actx, actx->discovery_uri))
      					goto error_return;
    @@ src/interfaces/libpq-oauth/oauth-curl.c: pg_fe_run_oauth_flow_impl(PGconn *conn)
     -	 * also the documentation for struct async_ctx.
     -	 */
     -	if (actx->errctx)
    --		appendPQExpBuffer(errbuf, "%s: ", libpq_gettext(actx->errctx));
    +-		appendPQExpBuffer(errbuf, "%s: ", actx->errctx);
     -
     -	if (PQExpBufferDataBroken(actx->errbuf))
     -		appendPQExpBufferStr(errbuf, libpq_gettext("out of memory"));
    @@ src/interfaces/libpq/fe-auth-oauth.c: oauth_init(PGconn *conn, const char *passw
      static void
      oauth_free(void *opaq)
     @@ src/interfaces/libpq/fe-auth-oauth.c: cleanup:
    - }
    - 
    - /*
    -- * Helper for handling user flow failures. If the implementation put anything
    -- * into request->error, it's added to conn->errorMessage here.
    -+ * Helper for handling flow failures. If the implementation put anything into
    -+ * request->error, it's added to conn->errorMessage here.
    +  * it's added to conn->errorMessage here.
       */
      static void
     -report_user_flow_error(PGconn *conn, const PGoauthBearerRequestV2 *request)
6:  dd491541a01 ! 6:  41132991bb4 libpq-oauth: Never link against libpq's encoding functions
    @@ Commit message
         dependency on the exported APIs will simply fail to link the shared
         module.
     
    +    Reviewed-by: Chao Li <li.evan.chao@gmail.com>
    +    Discussion: https://postgr.es/m/CAOYmi%2BmrGg%2Bn_X2MOLgeWcj3v_M00gR8uz_D7mM8z%3DdX1JYVbg%40mail.gmail.com
    +
      ## src/interfaces/libpq-oauth/meson.build ##
     @@ src/interfaces/libpq-oauth/meson.build: libpq_oauth_sources = files(
      libpq_oauth_so_sources = files(
7:  eda0c6f9059 ! 7:  e20b555aba5 WIP: Introduce third-party OAuth flow plugins?
    @@ Commit message
               new vulnerabilities
         TODO: how hard would it be to support Windows here?
     
    +    Discussion: https://postgr.es/m/CAOYmi%2BmrGg%2Bn_X2MOLgeWcj3v_M00gR8uz_D7mM8z%3DdX1JYVbg%40mail.gmail.com
    +
      ## src/interfaces/libpq/meson.build ##
     @@ src/interfaces/libpq/meson.build: pkgconfig.generate(
      install_headers(
    @@ src/test/modules/oauth_validator/t/002_client.pl: sub test
      
      test(
     @@ src/test/modules/oauth_validator/t/002_client.pl: test(
    - # Make sure the v1 hook continues to work. */
    + # Make sure the v1 hook continues to work.
      test(
      	"v1 synchronous hook can provide a token",
     +	hook_only => 1,    # plugins don't support API v1
v2-0001-libpq-fe.h-Don-t-claim-SOCKTYPE-in-the-global-nam.patchapplication/octet-stream; name=v2-0001-libpq-fe.h-Don-t-claim-SOCKTYPE-in-the-global-nam.patchDownload
From 8312bf3072601f03eecf55d31fba3584d214854f Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Mon, 1 Dec 2025 10:50:42 -0800
Subject: [PATCH v2 1/7] libpq-fe.h: Don't claim SOCKTYPE in the global
 namespace

The definition of PGoauthBearerRequest uses a temporary SOCKTYPE macro
to hide the difference between Windows and Berkeley socket handles,
since we don't surface pgsocket in our public API. This macro doesn't
need to escape the header, because implementers will choose the correct
socket type based on their platform, so I #undef'd it immediately after
use.

I didn't namespace that helper, though, so if anyone else needs a
SOCKTYPE macro, libpq-fe.h will now unhelpfully get rid of it. This
doesn't seem too far-fetched, given its proximity to existing POSIX
macro names.

Add a PQ_ prefix to avoid collisions, update and improve the surrounding
documentation, and backpatch.

Reviewed-by: Chao Li <li.evan.chao@gmail.com>
Discussion: https://postgr.es/m/CAOYmi%2BmrGg%2Bn_X2MOLgeWcj3v_M00gR8uz_D7mM8z%3DdX1JYVbg%40mail.gmail.com
Backpatch-through: 18
---
 doc/src/sgml/libpq.sgml         | 10 +++++++---
 src/interfaces/libpq/libpq-fe.h | 17 ++++++++++++-----
 2 files changed, 19 insertions(+), 8 deletions(-)

diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index 7ab679a765d..7d05938feda 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -10422,10 +10422,14 @@ typedef struct PGoauthBearerRequest
 
     /* Hook outputs */
 
-    /* Callback implementing a custom asynchronous OAuth flow. */
+    /*
+     * Callback implementing a custom asynchronous OAuth flow. The signature is
+     * platform-dependent: PQ_SOCKTYPE is SOCKET on Windows, and int everywhere
+     * else.
+     */
     PostgresPollingStatusType (*async) (PGconn *conn,
                                         struct PGoauthBearerRequest *request,
-                                        SOCKTYPE *altsock);
+                                        PQ_SOCKTYPE *altsock);
 
     /* Callback to clean up custom allocations. */
     void        (*cleanup) (PGconn *conn, struct PGoauthBearerRequest *request);
@@ -10482,7 +10486,7 @@ typedef struct PGoauthBearerRequest
          hook. When the callback cannot make further progress without blocking,
          it should return either <symbol>PGRES_POLLING_READING</symbol> or
          <symbol>PGRES_POLLING_WRITING</symbol> after setting
-         <literal>*pgsocket</literal> to the file descriptor that will be marked
+         <literal>*altsock</literal> to the file descriptor that will be marked
          ready to read/write when progress can be made again. (This descriptor
          is then provided to the top-level polling loop via
          <function>PQsocket()</function>.) Return <symbol>PGRES_POLLING_OK</symbol>
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index 0852584edae..877a6483b34 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -738,11 +738,15 @@ typedef struct _PGpromptOAuthDevice
 	int			expires_in;		/* seconds until user code expires */
 } PGpromptOAuthDevice;
 
-/* for PGoauthBearerRequest.async() */
+/*
+ * For PGoauthBearerRequest.async(). This macro just allows clients to avoid
+ * depending on libpq-int.h or Winsock for the "socket" type; it's undefined
+ * immediately below.
+ */
 #ifdef _WIN32
-#define SOCKTYPE uintptr_t		/* avoids depending on winsock2.h for SOCKET */
+#define PQ_SOCKTYPE uintptr_t	/* avoids depending on winsock2.h for SOCKET */
 #else
-#define SOCKTYPE int
+#define PQ_SOCKTYPE int
 #endif
 
 typedef struct PGoauthBearerRequest
@@ -768,10 +772,13 @@ typedef struct PGoauthBearerRequest
 	 * blocking during the original call to the PQAUTHDATA_OAUTH_BEARER_TOKEN
 	 * hook, it may be returned directly, but one of request->async or
 	 * request->token must be set by the hook.
+	 *
+	 * The (PQ_SOCKTYPE *) in the signature is a placeholder for the platform's
+	 * native socket type: (SOCKET *) on Windows, and (int *) everywhere else.
 	 */
 	PostgresPollingStatusType (*async) (PGconn *conn,
 										struct PGoauthBearerRequest *request,
-										SOCKTYPE * altsock);
+										PQ_SOCKTYPE * altsock);
 
 	/*
 	 * Callback to clean up custom allocations. A hook implementation may use
@@ -798,7 +805,7 @@ typedef struct PGoauthBearerRequest
 	void	   *user;
 } PGoauthBearerRequest;
 
-#undef SOCKTYPE
+#undef PQ_SOCKTYPE
 
 extern char *PQencryptPassword(const char *passwd, const char *user);
 extern char *PQencryptPasswordConn(PGconn *conn, const char *passwd, const char *user, const char *algorithm);
-- 
2.34.1

v2-0002-libpq-oauth-use-correct-c_args-in-meson.build.patchapplication/octet-stream; name=v2-0002-libpq-oauth-use-correct-c_args-in-meson.build.patchDownload
From 899597d5777ad0b40173a9e630d2a9e2541f740f Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Wed, 3 Dec 2025 08:55:51 -0800
Subject: [PATCH v2 2/7] libpq-oauth: use correct c_args in meson.build

Copy-paste bug from b0635bfda: libpq-oauth.so was being built with
libpq_so_c_args, rather than libpq_oauth_so_c_args. (At the moment, the
two lists are identical, but that won't be true forever.)

Reviewed-by: Chao Li <li.evan.chao@gmail.com>
Discussion: https://postgr.es/m/CAOYmi%2BmrGg%2Bn_X2MOLgeWcj3v_M00gR8uz_D7mM8z%3DdX1JYVbg%40mail.gmail.com
Backpatch-through: 18
---
 src/interfaces/libpq-oauth/meson.build | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/interfaces/libpq-oauth/meson.build b/src/interfaces/libpq-oauth/meson.build
index 505e1671b86..881e3f24f10 100644
--- a/src/interfaces/libpq-oauth/meson.build
+++ b/src/interfaces/libpq-oauth/meson.build
@@ -40,7 +40,7 @@ libpq_oauth_name = 'libpq-oauth-@0@'.format(pg_version_major)
 libpq_oauth_so = shared_module(libpq_oauth_name,
   libpq_oauth_sources + libpq_oauth_so_sources,
   include_directories: [libpq_oauth_inc, postgres_inc],
-  c_args: libpq_so_c_args,
+  c_args: libpq_oauth_so_c_args,
   c_pch: pch_postgres_fe_h,
   dependencies: [frontend_shlib_code, libpq, libpq_oauth_deps],
   link_depends: export_file,
-- 
2.34.1

v2-0003-oauth_validator-Avoid-races-in-log_check.patchapplication/octet-stream; name=v2-0003-oauth_validator-Avoid-races-in-log_check.patchDownload
From 953c743f6926a638a6888d00e33c7592c660c9e2 Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Thu, 4 Dec 2025 16:34:09 -0800
Subject: [PATCH v2 3/7] oauth_validator: Avoid races in log_check()

Commit e0f373ee4 fixed up races in Cluster::connect_fails when using
log_like. t/002_client.pl didn't get the memo, though, because it
doesn't use Test::Cluster to perform its custom hook tests. (This is
probably not an issue at the moment, since the log check is only done
after authentication success and not failure, but there's no reason to
wait for someone to hit it.)

Introduce the fix, based on debug2 logging, to its use of log_check() as
well, and move the logic into the test() helper so that any additions
don't need to continually duplicate it.

Reviewed-by: Chao Li <li.evan.chao@gmail.com>
Discussion: https://postgr.es/m/CAOYmi%2BmrGg%2Bn_X2MOLgeWcj3v_M00gR8uz_D7mM8z%3DdX1JYVbg%40mail.gmail.com
Backpatch-through: 18
---
 .../modules/oauth_validator/t/002_client.pl   | 24 ++++++++++++++-----
 1 file changed, 18 insertions(+), 6 deletions(-)

diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
index aac0220d215..e6c91fc911c 100644
--- a/src/test/modules/oauth_validator/t/002_client.pl
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -29,6 +29,8 @@ $node->init;
 $node->append_conf('postgresql.conf', "log_connections = all\n");
 $node->append_conf('postgresql.conf',
 	"oauth_validator_libraries = 'validator'\n");
+# Needed to inspect postmaster log after connection failure:
+$node->append_conf('postgresql.conf', "log_min_messages = debug2");
 $node->start;
 
 $node->safe_psql('postgres', 'CREATE USER test;');
@@ -47,7 +49,7 @@ local all test oauth issuer="$issuer" scope="$scope"
 });
 $node->reload;
 
-my $log_start = $node->wait_for_log(qr/reloading configuration files/);
+$node->wait_for_log(qr/reloading configuration files/);
 
 $ENV{PGOAUTHDEBUG} = "UNSAFE";
 
@@ -73,6 +75,7 @@ sub test
 	my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr);
 	note "running '" . join("' '", @cmd) . "'";
 
+	my $log_start = -s $node->logfile;
 	my ($stdout, $stderr) = run_command(\@cmd);
 
 	if (defined($params{expected_stdout}))
@@ -88,6 +91,18 @@ sub test
 	{
 		is($stderr, "", "$test_name: no stderr");
 	}
+
+	if (defined($params{log_like}))
+	{
+		# See Cluster::connect_fails(). To avoid races, we have to wait for the
+		# postmaster to flush the log for the finished connection.
+		$node->wait_for_log(
+			qr/DEBUG:  (?:00000: )?forked new client backend, pid=(\d+) socket.*DEBUG:  (?:00000: )?client backend \(PID \1\) exited with exit code \d/s,
+			$log_start);
+
+		$node->log_check("$test_name: log matches",
+			$log_start, log_like => $params{log_like});
+	}
 }
 
 test(
@@ -97,11 +112,8 @@ test(
 		"--expected-uri", "$issuer/.well-known/openid-configuration",
 		"--expected-scope", $scope,
 	],
-	expected_stdout => qr/connection succeeded/);
-
-$node->log_check("validator receives correct token",
-	$log_start,
-	log_like => [ qr/oauth_validator: token="my-token", role="$user"/, ]);
+	expected_stdout => qr/connection succeeded/,
+	log_like => [qr/oauth_validator: token="my-token", role="$user"/]);
 
 if ($ENV{with_libcurl} ne 'yes')
 {
-- 
2.34.1

v2-0004-libpq-Introduce-PQAUTHDATA_OAUTH_BEARER_TOKEN_V2.patchapplication/octet-stream; name=v2-0004-libpq-Introduce-PQAUTHDATA_OAUTH_BEARER_TOKEN_V2.patchDownload
From 3c047e6cf2429715a3dae17d37418be9bfd5fc1d Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Mon, 1 Dec 2025 15:07:26 -0800
Subject: [PATCH v2 4/7] libpq: Introduce PQAUTHDATA_OAUTH_BEARER_TOKEN_V2

For the libpq-oauth module to eventually make use of the
PGoauthBearerRequest API, it needs some additional functionality: the
derived Issuer ID for the authorization server needs to be provided by
libpq, and error messages need to be built without relying on PGconn
internals. These features seem useful for application hooks, too, so
that they don't each have to reinvent the wheel.

The original plan was for additions to PGoauthBearerRequest to be made
without a version bump to the PGauthData type, and that applications
would simply check a LIBPQ_HAS_* macro at compile time to decide whether
they could use the new features. That theoretically works for
applications linked against libpq, since it's not safe to downgrade
libpq from the version you've compiled against.

That strategy won't work for plugins, though, due to a complication
first noticed during the libpq-oauth module split: it's normal for a
plugin on disk to be *newer* than the libpq that's loading it, because
you might have upgraded your installation while an application was
running. (Put another way: a plugin architecture causes the compile-time
and run-time dependency arrows to point in opposite directions, so
plugins won't be able to rely on the LIBPQ_HAS_* macros to determine
what APIs are available to them.)

(TODO: Are there implications for our use of RTLD_NOW at dlopen() time?
Can this be improved?)

Instead, add a new struct which extends the "v1" PGoauthBearerRequest.
When an application hook receives PQAUTHDATA_OAUTH_BEARER_TOKEN_V2, it
may safely downcast the request pointer it receives in its callbacks to
make use of the new functionality. libpq will first try the new version,
then fall back to the old before giving up.

TODO: Error handling introduces a dependency on pqexpbuffer.h, which is
      technically considered internal... but we export the ABI for psql,
      so is it really internal at this point?
TODO: Could we just add to the end of PGoauthBearerRequest, and tell
      users not to use the additional fields if they have version 1?

Reviewed-by: Chao Li <li.evan.chao@gmail.com>
Discussion: https://postgr.es/m/CAOYmi%2BmrGg%2Bn_X2MOLgeWcj3v_M00gR8uz_D7mM8z%3DdX1JYVbg%40mail.gmail.com
---
 doc/src/sgml/libpq.sgml                       |  87 +++++++++++++++
 src/tools/pgindent/typedefs.list              |   1 +
 src/interfaces/libpq/libpq-fe.h               |  32 +++++-
 src/interfaces/libpq/fe-auth-oauth.c          | 104 +++++++++++++-----
 .../oauth_validator/oauth_hook_client.c       |  97 +++++++++++++++-
 .../modules/oauth_validator/t/002_client.pl   |  38 ++++++-
 6 files changed, 324 insertions(+), 35 deletions(-)

diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index 7d05938feda..54b539d8647 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -10350,6 +10350,7 @@ PQauthDataHook_type PQgetAuthDataHook(void);
         <indexterm><primary>PQAUTHDATA_PROMPT_OAUTH_DEVICE</primary></indexterm>
        </term>
        <listitem>
+        <para><emphasis>Available in PostgreSQL 18 and later.</emphasis></para>
         <para>
          Replaces the default user prompt during the builtin device
          authorization client flow. <replaceable>data</replaceable> points to
@@ -10402,6 +10403,7 @@ typedef struct _PGpromptOAuthDevice
         <indexterm><primary>PQAUTHDATA_OAUTH_BEARER_TOKEN</primary></indexterm>
        </term>
        <listitem>
+        <para><emphasis>Available in PostgreSQL 18 and later.</emphasis></para>
         <para>
          Adds a custom implementation of a flow, replacing the builtin flow if
          it is <link linkend="configure-option-with-libcurl">installed</link>.
@@ -10409,6 +10411,13 @@ typedef struct _PGpromptOAuthDevice
          user/issuer/scope combination, if one is available without blocking, or
          else set up an asynchronous callback to retrieve one.
         </para>
+        <note>
+         <para>
+          For <productname>PostgreSQL</productname> releases 19 and later,
+          applications should prefer
+          <link linkend="libpq-oauth-authdata-oauth-bearer-token-v2"><literal>PQAUTHDATA_OAUTH_BEARER_TOKEN_V2</literal></link>.
+         </para>
+        </note>
         <para>
          <replaceable>data</replaceable> points to an instance
          of <symbol>PGoauthBearerRequest</symbol>, which should be filled in
@@ -10504,6 +10513,84 @@ typedef struct PGoauthBearerRequest
         </para>
        </listitem>
       </varlistentry>
+
+      <varlistentry id="libpq-oauth-authdata-oauth-bearer-token-v2">
+       <term>
+        <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN_V2</symbol>
+        <indexterm>
+         <primary>PQAUTHDATA_OAUTH_BEARER_TOKEN_V2</primary>
+         <secondary>PQAUTHDATA_OAUTH_BEARER_TOKEN</secondary>
+        </indexterm>
+       </term>
+       <listitem>
+        <para><emphasis>Available in PostgreSQL 19 and later.</emphasis></para>
+        <para>
+         Provides all the functionality of
+         <link linkend="libpq-oauth-authdata-oauth-bearer-token"><literal>PQAUTHDATA_OAUTH_BEARER_TOKEN</literal></link>,
+         and adds the ability to set custom error messages and inspect the
+         OAuth issuer ID that the client expects to use.
+        </para>
+        <para>
+         <replaceable>data</replaceable> points to an instance
+         of <symbol>PGoauthBearerRequestV2</symbol>, which should be filled in
+         by the implementation:
+<synopsis>
+typedef struct
+{
+    PGoauthBearerRequest v1;    /* see the PGoauthBearerRequest struct, above */
+
+    /* Hook inputs (constant across all calls) */
+    const char *issuer;            /* the issuer identifier (RFC 9207) in use */
+
+    /* Hook outputs */
+
+    /* An initialized, empty buffer for reporting errors when the flow fails. */
+    struct PQExpBufferData *error;
+} PGoauthBearerRequestV2;
+</synopsis>
+        </para>
+        <para>
+         When a <literal>PQAUTHDATA_OAUTH_BEARER_TOKEN_V2</literal> hook is in
+         use, <application>libpq</application> additionally guarantees that the
+         <literal>request</literal> pointer that is provided to the
+         <replaceable>v1.async</replaceable> and <replaceable>v1.cleanup</replaceable>
+         callbacks may be safely cast to <literal>(PGoauthBearerRequestV2&nbsp;*)</literal>.
+         Implementations must otherwise follow the v1 API, as described above,
+         using the <replaceable>v1</replaceable> struct member.
+        </para>
+        <warning>
+         <para>
+          Casting to <literal>(PGoauthBearerRequestV2&nbsp;*)</literal> is
+          <emphasis>only</emphasis> safe when the hook type is
+          <literal>PQAUTHDATA_OAUTH_BEARER_TOKEN_V2</literal>. Applications may
+          crash or misbehave if a hook attempts to use v2 features with the v1
+          hook type.
+         </para>
+        </warning>
+        <para>
+         In addition to the functionality of the version 1 API, the v2 struct
+         provides an additional input and output for the hook:
+        </para>
+        <para>
+         <replaceable>issuer</replaceable> contains the issuer identifier, as
+         defined in <ulink url="https://datatracker.ietf.org/doc/html/rfc9207">RFC 9207</ulink>,
+         that is in use for the current connection. This identifier is
+         determined by <xref linkend="libpq-connect-oauth-issuer"/>.
+         To avoid mix-up attacks, custom flows should ensure that any discovery
+         metadata provided by the authorization server matches this issuer ID.
+        </para>
+        <para>
+         <replaceable>error</replaceable> contains an empty
+         <literal>PQExpBuffer</literal> that can be used to construct a custom
+         error message when a flow fails. The message will be included as part
+         of <link linkend="libpq-PQerrorMessage"><literal>PQerrorMessage()</literal></link>.
+         Hooks must not free or reassign this buffer; it is managed by
+         <application>libpq</application>. The API for manipulating the error
+         buffer is provided in <literal>"internal/pqexpbuffer.h"</literal>.
+        </para>
+       </listitem>
+      </varlistentry>
+
      </variablelist>
     </para>
    </sect3>
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 3451538565e..b3cc242a66b 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1916,6 +1916,7 @@ PGdataValue
 PGlobjfuncs
 PGnotify
 PGoauthBearerRequest
+PGoauthBearerRequestV2
 PGpipelineStatus
 PGpromptOAuthDevice
 PGresAttDesc
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index 877a6483b34..e108e6629f1 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -63,6 +63,10 @@ extern "C"
 /* Indicates presence of the PQAUTHDATA_PROMPT_OAUTH_DEVICE authdata hook */
 #define LIBPQ_HAS_PROMPT_OAUTH_DEVICE 1
 
+/* Features added in PostgreSQL v19: */
+/* Indicates presence of the PQAUTHDATA_OAUTH_BEARER_TOKEN_V2 authdata hook */
+#define LIBPQ_HAS_OAUTH_BEARER_TOKEN_V2 1
+
 /*
  * Option flags for PQcopyResult
  */
@@ -193,7 +197,9 @@ typedef enum
 {
 	PQAUTHDATA_PROMPT_OAUTH_DEVICE, /* user must visit a device-authorization
 									 * URL */
-	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token */
+	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token
+									 * (v2 is preferred; see below) */
+	PQAUTHDATA_OAUTH_BEARER_TOKEN_V2,	/* newest API for OAuth Bearer tokens */
 } PGauthData;
 
 /* PGconn encapsulates a connection to the backend.
@@ -729,6 +735,7 @@ extern int	PQenv2encoding(void);
 
 /* === in fe-auth.c === */
 
+/* Authdata for PQAUTHDATA_PROMPT_OAUTH_DEVICE */
 typedef struct _PGpromptOAuthDevice
 {
 	const char *verification_uri;	/* verification URI to visit */
@@ -749,6 +756,7 @@ typedef struct _PGpromptOAuthDevice
 #define PQ_SOCKTYPE int
 #endif
 
+/* Authdata for PQAUTHDATA_OAUTH_BEARER_TOKEN */
 typedef struct PGoauthBearerRequest
 {
 	/* Hook inputs (constant across all calls) */
@@ -807,6 +815,28 @@ typedef struct PGoauthBearerRequest
 
 #undef PQ_SOCKTYPE
 
+struct PQExpBufferData;			/* see pqexpbuffer.h */
+
+/* Authdata for PQAUTHDATA_OAUTH_BEARER_TOKEN_V2 */
+typedef struct
+{
+	PGoauthBearerRequest v1;	/* see the PGoauthBearerRequest struct, above */
+
+	/* Hook inputs (constant across all calls) */
+	const char *issuer;			/* the issuer identifier (RFC 9207) in use, as
+								 * derived from the connection's oauth_issuer */
+
+	/* Hook outputs */
+
+	/*
+	 * An initialized, empty buffer for reporting errors when the flow fails.
+	 * Implementations may use the API in pqexpbuffer.h to build a custom
+	 * error description. It will be included in the connection's
+	 * PQerrorMessage() output.
+	 */
+	struct PQExpBufferData *error;
+} PGoauthBearerRequestV2;
+
 extern char *PQencryptPassword(const char *passwd, const char *user);
 extern char *PQencryptPasswordConn(PGconn *conn, const char *passwd, const char *user, const char *algorithm);
 extern PGresult *PQchangePassword(PGconn *conn, const char *user, const char *passwd);
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
index fb63a3249d6..f94bf31b2ea 100644
--- a/src/interfaces/libpq/fe-auth-oauth.c
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -675,6 +675,32 @@ cleanup:
 	return success;
 }
 
+/*
+ * Helper for handling flow failures. If anything was put into request->error,
+ * it's added to conn->errorMessage here.
+ */
+static void
+report_user_flow_error(PGconn *conn, const PGoauthBearerRequestV2 *request)
+{
+	const char *errmsg = NULL;
+
+	if (PQExpBufferBroken(request->error))
+		errmsg = libpq_gettext("out of memory");
+	else if (request->error->len)
+		errmsg = request->error->data;
+
+	appendPQExpBufferStr(&conn->errorMessage,
+						 libpq_gettext("user-defined OAuth flow failed"));
+
+	if (errmsg)
+	{
+		appendPQExpBufferStr(&conn->errorMessage, ": ");
+		appendPQExpBufferStr(&conn->errorMessage, errmsg);
+	}
+
+	appendPQExpBufferChar(&conn->errorMessage, '\n');
+}
+
 /*
  * Callback implementation of conn->async_auth() for a user-defined OAuth flow.
  * Delegates the retrieval of the token to the application's async callback.
@@ -687,20 +713,23 @@ static PostgresPollingStatusType
 run_user_oauth_flow(PGconn *conn)
 {
 	fe_oauth_state *state = conn->sasl_state;
-	PGoauthBearerRequest *request = state->async_ctx;
+	PGoauthBearerRequestV2 *request = state->async_ctx;
 	PostgresPollingStatusType status;
 
-	if (!request->async)
+	if (!request->v1.async)
 	{
 		libpq_append_conn_error(conn,
 								"user-defined OAuth flow provided neither a token nor an async callback");
 		return PGRES_POLLING_FAILED;
 	}
 
-	status = request->async(conn, request, &conn->altsock);
+	status = request->v1.async(conn,
+							   (PGoauthBearerRequest *) request,
+							   &conn->altsock);
+
 	if (status == PGRES_POLLING_FAILED)
 	{
-		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		report_user_flow_error(conn, request);
 		return status;
 	}
 	else if (status == PGRES_POLLING_OK)
@@ -710,14 +739,14 @@ run_user_oauth_flow(PGconn *conn)
 		 * onto the original string, since it may not be safe for us to free()
 		 * it.)
 		 */
-		if (!request->token)
+		if (!request->v1.token)
 		{
 			libpq_append_conn_error(conn,
 									"user-defined OAuth flow did not provide a token");
 			return PGRES_POLLING_FAILED;
 		}
 
-		conn->oauth_token = strdup(request->token);
+		conn->oauth_token = strdup(request->v1.token);
 		if (!conn->oauth_token)
 		{
 			libpq_append_conn_error(conn, "out of memory");
@@ -739,21 +768,23 @@ run_user_oauth_flow(PGconn *conn)
 }
 
 /*
- * Cleanup callback for the async user flow. Delegates most of its job to the
- * user-provided cleanup implementation, then disconnects the altsock.
+ * Cleanup callback for the async user flow. Delegates most of its job to
+ * PGoauthBearerRequestV2.cleanup(), then disconnects the altsock and frees the
+ * request itself.
  */
 static void
 cleanup_user_oauth_flow(PGconn *conn)
 {
 	fe_oauth_state *state = conn->sasl_state;
-	PGoauthBearerRequest *request = state->async_ctx;
+	PGoauthBearerRequestV2 *request = state->async_ctx;
 
 	Assert(request);
 
-	if (request->cleanup)
-		request->cleanup(conn, request);
+	if (request->v1.cleanup)
+		request->v1.cleanup(conn, (PGoauthBearerRequest *) request);
 	conn->altsock = PGINVALID_SOCKET;
 
+	destroyPQExpBuffer(request->error);
 	free(request);
 	state->async_ctx = NULL;
 }
@@ -975,8 +1006,8 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state)
  * token for presentation to the server.
  *
  * If the application has registered a custom flow handler using
- * PQAUTHDATA_OAUTH_BEARER_TOKEN, it may either return a token immediately (e.g.
- * if it has one cached for immediate use), or set up for a series of
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN[_V2], it may either return a token immediately
+ * (e.g. if it has one cached for immediate use), or set up for a series of
  * asynchronous callbacks which will be managed by run_user_oauth_flow().
  *
  * If the default handler is used instead, a Device Authorization flow is used
@@ -990,27 +1021,44 @@ static bool
 setup_token_request(PGconn *conn, fe_oauth_state *state)
 {
 	int			res;
-	PGoauthBearerRequest request = {
-		.openid_configuration = conn->oauth_discovery_uri,
-		.scope = conn->oauth_scope,
+	PGoauthBearerRequestV2 request = {
+		.v1 = {
+			.openid_configuration = conn->oauth_discovery_uri,
+			.scope = conn->oauth_scope,
+		},
+		.issuer = conn->oauth_issuer_id,
+		.error = createPQExpBuffer(),
 	};
 
-	Assert(request.openid_configuration);
+	Assert(request.v1.openid_configuration);
+	Assert(request.issuer);
+
+	if (PQExpBufferBroken(request.error))
+	{
+		libpq_append_conn_error(conn, "out of memory");
+		return false;
+	}
+
+	/*
+	 * The client may have overridden the OAuth flow. Try the v2 hook first,
+	 * then fall back to the v1 implementation.
+	 */
+	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN_V2, conn, &request);
+	if (res == 0)
+		res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
 
-	/* The client may have overridden the OAuth flow. */
-	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
 	if (res > 0)
 	{
-		PGoauthBearerRequest *request_copy;
+		PGoauthBearerRequestV2 *request_copy;
 
-		if (request.token)
+		if (request.v1.token)
 		{
 			/*
 			 * 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.)
 			 */
-			conn->oauth_token = strdup(request.token);
+			conn->oauth_token = strdup(request.v1.token);
 			if (!conn->oauth_token)
 			{
 				libpq_append_conn_error(conn, "out of memory");
@@ -1018,8 +1066,9 @@ setup_token_request(PGconn *conn, fe_oauth_state *state)
 			}
 
 			/* short-circuit */
-			if (request.cleanup)
-				request.cleanup(conn, &request);
+			if (request.v1.cleanup)
+				request.v1.cleanup(conn, (PGoauthBearerRequest *) &request);
+			destroyPQExpBuffer(request.error);
 			return true;
 		}
 
@@ -1038,7 +1087,7 @@ setup_token_request(PGconn *conn, fe_oauth_state *state)
 	}
 	else if (res < 0)
 	{
-		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		report_user_flow_error(conn, &request);
 		goto fail;
 	}
 	else if (!use_builtin_flow(conn, state))
@@ -1050,8 +1099,9 @@ setup_token_request(PGconn *conn, fe_oauth_state *state)
 	return true;
 
 fail:
-	if (request.cleanup)
-		request.cleanup(conn, &request);
+	if (request.v1.cleanup)
+		request.v1.cleanup(conn, (PGoauthBearerRequest *) &request);
+	destroyPQExpBuffer(request.error);
 	return false;
 }
 
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
index 15d0cf938a8..b1a3b014079 100644
--- a/src/test/modules/oauth_validator/oauth_hook_client.c
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -20,6 +20,7 @@
 
 #include "getopt_long.h"
 #include "libpq-fe.h"
+#include "pqexpbuffer.h"
 
 static int	handle_auth_data(PGauthData type, PGconn *conn, void *data);
 static PostgresPollingStatusType async_cb(PGconn *conn,
@@ -36,13 +37,16 @@ usage(char *argv[])
 
 	printf("recognized flags:\n");
 	printf("  -h, --help              show this message\n");
+	printf("  -v VERSION              select the hook API version (default 2)\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("  --expected-issuer ISS   fail if received issuer does not match ISS (v2 only)\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\n");
 	printf("  --hang-forever          don't ever return a token (combine with connect_timeout)\n");
 	printf("  --token TOKEN           use the provided TOKEN value\n");
+	printf("  --error ERRMSG          fail instead, with the given ERRMSG (v2 only)\n");
 	printf("  --stress-async          busy-loop on PQconnectPoll rather than polling\n");
 }
 
@@ -51,9 +55,12 @@ static bool no_hook = false;
 static bool hang_forever = false;
 static bool stress_async = false;
 static const char *expected_uri = NULL;
+static const char *expected_issuer = NULL;
 static const char *expected_scope = NULL;
 static const char *misbehave_mode = NULL;
 static char *token = NULL;
+static char *errmsg = NULL;
+static int	hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN_V2;
 
 int
 main(int argc, char *argv[])
@@ -68,6 +75,8 @@ main(int argc, char *argv[])
 		{"hang-forever", no_argument, NULL, 1004},
 		{"misbehave", required_argument, NULL, 1005},
 		{"stress-async", no_argument, NULL, 1006},
+		{"expected-issuer", required_argument, NULL, 1007},
+		{"error", required_argument, NULL, 1008},
 		{0}
 	};
 
@@ -75,7 +84,7 @@ main(int argc, char *argv[])
 	PGconn	   *conn;
 	int			c;
 
-	while ((c = getopt_long(argc, argv, "h", long_options, NULL)) != -1)
+	while ((c = getopt_long(argc, argv, "hv:", long_options, NULL)) != -1)
 	{
 		switch (c)
 		{
@@ -83,6 +92,18 @@ main(int argc, char *argv[])
 				usage(argv);
 				return 0;
 
+			case 'v':
+				if (strcmp(optarg, "1") == 0)
+					hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN;
+				else if (strcmp(optarg, "2") == 0)
+					hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN_V2;
+				else
+				{
+					usage(argv);
+					return 1;
+				}
+				break;
+
 			case 1000:			/* --expected-scope */
 				expected_scope = optarg;
 				break;
@@ -111,6 +132,14 @@ main(int argc, char *argv[])
 				stress_async = true;
 				break;
 
+			case 1007:			/* --expected-issuer */
+				expected_issuer = optarg;
+				break;
+
+			case 1008:			/* --error */
+				errmsg = optarg;
+				break;
+
 			default:
 				usage(argv);
 				return 1;
@@ -167,16 +196,24 @@ main(int argc, char *argv[])
 
 /*
  * PQauthDataHook implementation. Replaces the default client flow by handling
- * PQAUTHDATA_OAUTH_BEARER_TOKEN.
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN[_V2].
  */
 static int
 handle_auth_data(PGauthData type, PGconn *conn, void *data)
 {
-	PGoauthBearerRequest *req = data;
+	PGoauthBearerRequest *req;
+	PGoauthBearerRequestV2 *req2 = NULL;
+
+	Assert(hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN ||
+		   hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN_V2);
 
-	if (no_hook || (type != PQAUTHDATA_OAUTH_BEARER_TOKEN))
+	if (no_hook || type != hook_version)
 		return 0;
 
+	req = data;
+	if (type == PQAUTHDATA_OAUTH_BEARER_TOKEN_V2)
+		req2 = data;
+
 	if (hang_forever)
 	{
 		/* Start asynchronous processing. */
@@ -221,6 +258,44 @@ handle_auth_data(PGauthData type, PGconn *conn, void *data)
 		}
 	}
 
+	if (expected_issuer)
+	{
+		if (!req2)
+		{
+			fprintf(stderr, "--expected-issuer cannot be combined with -v1\n");
+			return -1;
+		}
+
+		if (!req2->issuer)
+		{
+			fprintf(stderr, "expected issuer \"%s\", got NULL\n", expected_issuer);
+			return -1;
+		}
+
+		if (strcmp(expected_issuer, req2->issuer) != 0)
+		{
+			fprintf(stderr, "expected issuer \"%s\", got \"%s\"\n", expected_issuer, req2->issuer);
+			return -1;
+		}
+	}
+
+	if (errmsg)
+	{
+		if (token)
+		{
+			fprintf(stderr, "--error cannot be combined with --token\n");
+			return -1;
+		}
+		else if (!req2)
+		{
+			fprintf(stderr, "--error cannot be combined with -v1\n");
+			return -1;
+		}
+
+		appendPQExpBufferStr(req2->error, errmsg);
+		return -1;
+	}
+
 	req->token = token;
 	return 1;
 }
@@ -273,6 +348,20 @@ misbehave_cb(PGconn *conn, PGoauthBearerRequest *req, pgsocket *altsock)
 	if (strcmp(misbehave_mode, "fail-async") == 0)
 	{
 		/* Just fail "normally". */
+		if (errmsg)
+		{
+			PGoauthBearerRequestV2 *req2;
+
+			if (hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN)
+			{
+				fprintf(stderr, "--error cannot be combined with -v1\n");
+				exit(1);
+			}
+
+			req2 = (PGoauthBearerRequestV2 *) req;
+			appendPQExpBufferStr(req2->error, errmsg);
+		}
+
 		return PGRES_POLLING_FAILED;
 	}
 	else if (strcmp(misbehave_mode, "no-token") == 0)
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
index e6c91fc911c..f878a23d201 100644
--- a/src/test/modules/oauth_validator/t/002_client.pl
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -78,9 +78,9 @@ sub test
 	my $log_start = -s $node->logfile;
 	my ($stdout, $stderr) = run_command(\@cmd);
 
-	if (defined($params{expected_stdout}))
+	if ($params{expect_success})
 	{
-		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
+		like($stdout, qr/connection succeeded/, "$test_name: stdout matches");
 	}
 
 	if (defined($params{expected_stderr}))
@@ -110,11 +110,24 @@ test(
 	flags => [
 		"--token", "my-token",
 		"--expected-uri", "$issuer/.well-known/openid-configuration",
+		"--expected-issuer", "$issuer",
 		"--expected-scope", $scope,
 	],
-	expected_stdout => qr/connection succeeded/,
+	expect_success => 1,
 	log_like => [qr/oauth_validator: token="my-token", role="$user"/]);
 
+# Make sure the v1 hook continues to work.
+test(
+	"v1 synchronous hook can provide a token",
+	flags => [
+		"-v1",
+		"--token" => "my-token-v1",
+		"--expected-uri" => "$issuer/.well-known/openid-configuration",
+		"--expected-scope" => $scope,
+	],
+	expect_success => 1,
+	log_like => [qr/oauth_validator: token="my-token-v1", role="$user"/]);
+
 if ($ENV{with_libcurl} ne 'yes')
 {
 	# libpq should help users out if no OAuth support is built in.
@@ -126,6 +139,15 @@ if ($ENV{with_libcurl} ne 'yes')
 	);
 }
 
+# v2 synchronous flows should be able to set custom error messages.
+test(
+	"basic synchronous hook can set error messages",
+	flags => [
+		"--error" => "a custom error message",
+	],
+	expected_stderr =>
+	  qr/user-defined OAuth flow failed: a custom error message/);
+
 # connect_timeout should work if the flow doesn't respond.
 $common_connstr = "$common_connstr connect_timeout=1";
 test(
@@ -165,4 +187,14 @@ foreach my $c (@cases)
 		expected_stderr => $c->{'expected_error'});
 }
 
+# v2 async flows should be able to set error messages, too.
+test(
+	"asynchronous hook can set error messages",
+	flags => [
+		"--misbehave" => "fail-async",
+		"--error" => "async error message",
+	],
+	expected_stderr =>
+	  qr/user-defined OAuth flow failed: async error message/);
+
 done_testing();
-- 
2.34.1

v2-0005-libpq-oauth-Use-the-PGoauthBearerRequestV2-API.patchapplication/octet-stream; name=v2-0005-libpq-oauth-Use-the-PGoauthBearerRequestV2-API.patchDownload
From e655d4ee091c91f4c18695a277c1b0f6507f7d10 Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Thu, 23 Oct 2025 12:24:20 -0700
Subject: [PATCH v2 5/7] libpq-oauth: Use the PGoauthBearerRequestV2 API

Switch the private libpq-oauth ABI to the public PGoauthBearerRequestV2
API. A huge amount of glue code can be removed as part of this, and several
code paths can be deduplicated. Additionally, the shared library no
longer needs to change its name for every major release; it's now just
"libpq-oauth.so".

TODO: This API might need a public initialization function in addition
      to the private NLS initialization. Should we expose the lock to
      that function as well?

Reviewed-by: Chao Li <li.evan.chao@gmail.com>
Discussion: https://postgr.es/m/CAOYmi%2BmrGg%2Bn_X2MOLgeWcj3v_M00gR8uz_D7mM8z%3DdX1JYVbg%40mail.gmail.com
---
 src/interfaces/libpq-oauth/README        |  59 ++--
 src/interfaces/libpq-oauth/exports.txt   |   3 +-
 src/interfaces/libpq-oauth/meson.build   |   4 +-
 src/interfaces/libpq-oauth/Makefile      |   9 +-
 src/interfaces/libpq-oauth/oauth-curl.h  |   6 +-
 src/interfaces/libpq-oauth/oauth-utils.h |  42 +--
 src/interfaces/libpq/fe-auth-oauth.h     |   7 +-
 src/interfaces/libpq-oauth/oauth-curl.c  | 347 +++++++++++++----------
 src/interfaces/libpq-oauth/oauth-utils.c |  60 +---
 src/interfaces/libpq/fe-auth-oauth.c     | 194 ++++++-------
 10 files changed, 327 insertions(+), 404 deletions(-)

diff --git a/src/interfaces/libpq-oauth/README b/src/interfaces/libpq-oauth/README
index 553962d644e..276e67735f0 100644
--- a/src/interfaces/libpq-oauth/README
+++ b/src/interfaces/libpq-oauth/README
@@ -10,48 +10,35 @@ results in a failed connection.
 
 = Load-Time ABI =
 
-This module ABI is an internal implementation detail, so it's subject to change
-across major releases; the name of the module (libpq-oauth-MAJOR) reflects this.
-The module exports the following symbols:
+As of v19, this module ABI is public and cannot change incompatibly without also
+changing the entry points. Both libpq and libpq-oauth must gracefully handle
+situations where the other library is of a different release version, past or
+future, since upgrades to the libraries may happen in either order.
 
-- PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
-- void pg_fe_cleanup_oauth_flow(PGconn *conn);
+(Don't assume that package version dependencies from libpq-oauth to libpq will
+simplify the situation! Since libpq delay-loads libpq-oauth, we still have to
+handle cases where a long-running client application has a libpq that's older
+than a newly upgraded plugin.)
 
-pg_fe_run_oauth_flow and pg_fe_cleanup_oauth_flow are implementations of
-conn->async_auth and conn->cleanup_async_auth, respectively.
+The module exports the following symbol:
 
-At the moment, pg_fe_run_oauth_flow() relies on libpq's pg_g_threadlock and
-libpq_gettext(), which must be injected by libpq using this initialization
-function before the flow is run:
+- int pg_start_oauthbearer(PGconn *conn, PGoauthBearerRequestV2 *request);
+
+The module then behaves as if it had received a PQAUTHDATA_OAUTH_BEARER_TOKEN_V2
+request via the PQauthDataHook API, and it either fills in an existing token or
+populates the necessary callbacks for a token to be obtained asynchronously.
+(See the documentation for PGoauthBearerRequest.) The function returns zero on
+success and nonzero on failure.
+
+Additionally, libpq-oauth relies on libpq's pg_g_threadlock and libpq_gettext(),
+which must be injected by libpq using this initialization function before the
+flow is run:
 
 - void libpq_oauth_init(pgthreadlock_t threadlock,
-						libpq_gettext_func gettext_impl,
-						conn_errorMessage_func errmsg_impl,
-						conn_oauth_client_id_func clientid_impl,
-						conn_oauth_client_secret_func clientsecret_impl,
-						conn_oauth_discovery_uri_func discoveryuri_impl,
-						conn_oauth_issuer_id_func issuerid_impl,
-						conn_oauth_scope_func scope_impl,
-						conn_sasl_state_func saslstate_impl,
-						set_conn_altsock_func setaltsock_impl,
-						set_conn_oauth_token_func settoken_impl);
-
-It also relies on access to several members of the PGconn struct. Not only can
-these change positions across minor versions, but the offsets aren't necessarily
-stable within a single minor release (conn->errorMessage, for instance, can
-change offsets depending on configure-time options). Therefore the necessary
-accessors (named conn_*) and mutators (set_conn_*) are injected here. With this
-approach, we can safely search the standard dlopen() paths (e.g. RPATH,
-LD_LIBRARY_PATH, the SO cache) for an implementation module to use, even if that
-module wasn't compiled at the same time as libpq -- which becomes especially
-important during "live upgrade" situations where a running libpq application has
-the libpq-oauth module updated out from under it before it's first loaded from
-disk.
+						libpq_gettext_func gettext_impl);
 
 = Static Build =
 
 The static library libpq.a does not perform any dynamic loading. If the builtin
-flow is enabled, the application is expected to link against libpq-oauth.a
-directly to provide the necessary symbols. (libpq.a and libpq-oauth.a must be
-part of the same build. Unlike the dynamic module, there are no translation
-shims provided.)
+flow is enabled, the application is expected to link against libpq-oauth.a to
+provide the necessary symbol, or else implement pg_start_oauthbearer() itself.
diff --git a/src/interfaces/libpq-oauth/exports.txt b/src/interfaces/libpq-oauth/exports.txt
index 6891a83dbf9..7bc12b860d7 100644
--- a/src/interfaces/libpq-oauth/exports.txt
+++ b/src/interfaces/libpq-oauth/exports.txt
@@ -1,4 +1,3 @@
 # src/interfaces/libpq-oauth/exports.txt
 libpq_oauth_init          1
-pg_fe_run_oauth_flow      2
-pg_fe_cleanup_oauth_flow  3
+pg_start_oauthbearer      2
diff --git a/src/interfaces/libpq-oauth/meson.build b/src/interfaces/libpq-oauth/meson.build
index 881e3f24f10..11199100b28 100644
--- a/src/interfaces/libpq-oauth/meson.build
+++ b/src/interfaces/libpq-oauth/meson.build
@@ -35,9 +35,7 @@ libpq_oauth_st = static_library('libpq-oauth',
 
 # This is an internal module; we don't want an SONAME and therefore do not set
 # SO_MAJOR_VERSION.
-libpq_oauth_name = 'libpq-oauth-@0@'.format(pg_version_major)
-
-libpq_oauth_so = shared_module(libpq_oauth_name,
+libpq_oauth_so = shared_module('libpq-oauth',
   libpq_oauth_sources + libpq_oauth_so_sources,
   include_directories: [libpq_oauth_inc, postgres_inc],
   c_args: libpq_oauth_so_c_args,
diff --git a/src/interfaces/libpq-oauth/Makefile b/src/interfaces/libpq-oauth/Makefile
index 51145f085a8..0febb393feb 100644
--- a/src/interfaces/libpq-oauth/Makefile
+++ b/src/interfaces/libpq-oauth/Makefile
@@ -16,13 +16,10 @@ include $(top_builddir)/src/Makefile.global
 PGFILEDESC = "libpq-oauth - device authorization OAuth support"
 
 # This is an internal module; we don't want an SONAME and therefore do not set
-# SO_MAJOR_VERSION.
-NAME = pq-oauth-$(MAJORVERSION)
-
-# Force the name "libpq-oauth" for both the static and shared libraries. The
-# staticlib doesn't need version information in its name.
+# SO_MAJOR_VERSION. This requires an explicit override for the shared library
+# name.
+NAME = pq-oauth
 override shlib := lib$(NAME)$(DLSUFFIX)
-override stlib := libpq-oauth.a
 
 override CPPFLAGS := -I$(libpq_srcdir) -I$(top_builddir)/src/port $(CPPFLAGS) $(LIBCURL_CPPFLAGS)
 override CFLAGS += $(PTHREAD_CFLAGS)
diff --git a/src/interfaces/libpq-oauth/oauth-curl.h b/src/interfaces/libpq-oauth/oauth-curl.h
index 248d0424ad0..47704689586 100644
--- a/src/interfaces/libpq-oauth/oauth-curl.h
+++ b/src/interfaces/libpq-oauth/oauth-curl.h
@@ -17,8 +17,8 @@
 
 #include "libpq-fe.h"
 
-/* Exported async-auth callbacks. */
-extern PGDLLEXPORT PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
-extern PGDLLEXPORT void pg_fe_cleanup_oauth_flow(PGconn *conn);
+/* Exported flow callback. */
+extern PGDLLEXPORT int pg_start_oauthbearer(PGconn *conn,
+											PGoauthBearerRequestV2 *request);
 
 #endif							/* OAUTH_CURL_H */
diff --git a/src/interfaces/libpq-oauth/oauth-utils.h b/src/interfaces/libpq-oauth/oauth-utils.h
index f4ffefef208..343aa95cacf 100644
--- a/src/interfaces/libpq-oauth/oauth-utils.h
+++ b/src/interfaces/libpq-oauth/oauth-utils.h
@@ -15,53 +15,14 @@
 #ifndef OAUTH_UTILS_H
 #define OAUTH_UTILS_H
 
-#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 #include "pqexpbuffer.h"
 
-/*
- * A bank of callbacks to safely access members of PGconn, which are all passed
- * to libpq_oauth_init() by libpq.
- *
- * Keep these aligned with the definitions in fe-auth-oauth.c as well as the
- * static declarations in oauth-curl.c.
- */
-#define DECLARE_GETTER(TYPE, MEMBER) \
-	typedef TYPE (*conn_ ## MEMBER ## _func) (PGconn *conn); \
-	extern conn_ ## MEMBER ## _func conn_ ## MEMBER;
-
-#define DECLARE_SETTER(TYPE, MEMBER) \
-	typedef void (*set_conn_ ## MEMBER ## _func) (PGconn *conn, TYPE val); \
-	extern set_conn_ ## MEMBER ## _func set_conn_ ## MEMBER;
-
-DECLARE_GETTER(PQExpBuffer, errorMessage);
-DECLARE_GETTER(char *, oauth_client_id);
-DECLARE_GETTER(char *, oauth_client_secret);
-DECLARE_GETTER(char *, oauth_discovery_uri);
-DECLARE_GETTER(char *, oauth_issuer_id);
-DECLARE_GETTER(char *, oauth_scope);
-DECLARE_GETTER(fe_oauth_state *, sasl_state);
-
-DECLARE_SETTER(pgsocket, altsock);
-DECLARE_SETTER(char *, oauth_token);
-
-#undef DECLARE_GETTER
-#undef DECLARE_SETTER
-
 typedef char *(*libpq_gettext_func) (const char *msgid);
 
 /* Initializes libpq-oauth. */
 extern PGDLLEXPORT void libpq_oauth_init(pgthreadlock_t threadlock,
-										 libpq_gettext_func gettext_impl,
-										 conn_errorMessage_func errmsg_impl,
-										 conn_oauth_client_id_func clientid_impl,
-										 conn_oauth_client_secret_func clientsecret_impl,
-										 conn_oauth_discovery_uri_func discoveryuri_impl,
-										 conn_oauth_issuer_id_func issuerid_impl,
-										 conn_oauth_scope_func scope_impl,
-										 conn_sasl_state_func saslstate_impl,
-										 set_conn_altsock_func setaltsock_impl,
-										 set_conn_oauth_token_func settoken_impl);
+										 libpq_gettext_func gettext_impl);
 
 /*
  * Duplicated APIs, copied from libpq (primarily libpq-int.h, which we cannot
@@ -75,7 +36,6 @@ typedef enum
 	PG_BOOL_NO					/* No (false) */
 } PGTernaryBool;
 
-extern void libpq_append_conn_error(PGconn *conn, const char *fmt,...) pg_attribute_printf(2, 3);
 extern bool oauth_unsafe_debugging_enabled(void);
 extern int	pq_block_sigpipe(sigset_t *osigset, bool *sigpipe_pending);
 extern void pq_reset_sigpipe(sigset_t *osigset, bool sigpipe_pending, bool got_epipe);
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
index 0d59e91605b..b9aed879e64 100644
--- a/src/interfaces/libpq/fe-auth-oauth.h
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -27,11 +27,6 @@ enum fe_oauth_step
 	FE_OAUTH_SERVER_ERROR,
 };
 
-/*
- * This struct is exported to the libpq-oauth module. If changes are needed
- * during backports to stable branches, please keep ABI compatibility (no
- * changes to existing members, add new members at the end, etc.).
- */
 typedef struct
 {
 	enum fe_oauth_step step;
@@ -39,12 +34,12 @@ typedef struct
 	PGconn	   *conn;
 	void	   *async_ctx;
 
+	bool		builtin;
 	void	   *builtin_flow;
 } fe_oauth_state;
 
 extern void pqClearOAuthToken(PGconn *conn);
 extern bool oauth_unsafe_debugging_enabled(void);
-extern bool use_builtin_flow(PGconn *conn, fe_oauth_state *state);
 
 /* Mechanisms in fe-auth-oauth.c */
 extern const pg_fe_sasl_mech pg_oauth_mech;
diff --git a/src/interfaces/libpq-oauth/oauth-curl.c b/src/interfaces/libpq-oauth/oauth-curl.c
index 92a7f1cd383..b94458c860f 100644
--- a/src/interfaces/libpq-oauth/oauth-curl.c
+++ b/src/interfaces/libpq-oauth/oauth-curl.c
@@ -29,7 +29,6 @@
 #endif
 
 #include "common/jsonapi.h"
-#include "fe-auth-oauth.h"
 #include "mb/pg_wchar.h"
 #include "oauth-curl.h"
 
@@ -44,23 +43,10 @@
 
 #else							/* !USE_DYNAMIC_OAUTH */
 
-/*
- * Static builds may rely on PGconn offsets directly. Keep these aligned with
- * the bank of callbacks in oauth-utils.h.
- */
+/* Static builds may make use of libpq internals directly. */
+#include "fe-auth-oauth.h"
 #include "libpq-int.h"
 
-#define conn_errorMessage(CONN) (&CONN->errorMessage)
-#define conn_oauth_client_id(CONN) (CONN->oauth_client_id)
-#define conn_oauth_client_secret(CONN) (CONN->oauth_client_secret)
-#define conn_oauth_discovery_uri(CONN) (CONN->oauth_discovery_uri)
-#define conn_oauth_issuer_id(CONN) (CONN->oauth_issuer_id)
-#define conn_oauth_scope(CONN) (CONN->oauth_scope)
-#define conn_sasl_state(CONN) (CONN->sasl_state)
-
-#define set_conn_altsock(CONN, VAL) do { CONN->altsock = VAL; } while (0)
-#define set_conn_oauth_token(CONN, VAL) do { CONN->oauth_token = VAL; } while (0)
-
 #endif							/* USE_DYNAMIC_OAUTH */
 
 /* One final guardrail against accidental inclusion... */
@@ -227,6 +213,15 @@ enum OAuthStep
  */
 struct async_ctx
 {
+	/* relevant connection options cached from the PGconn */
+	char	   *client_id;		/* oauth_client_id */
+	char	   *client_secret;	/* oauth_client_secret (may be NULL) */
+
+	/* options cached from the PGoauthBearerRequest (we don't own these) */
+	const char *discovery_uri;
+	const char *issuer_id;
+	const char *scope;
+
 	enum OAuthStep step;		/* where are we in the flow? */
 
 	int			timerfd;		/* descriptor for signaling async timeouts */
@@ -286,7 +281,7 @@ struct async_ctx
  * Tears down the Curl handles and frees the async_ctx.
  */
 static void
-free_async_ctx(PGconn *conn, struct async_ctx *actx)
+free_async_ctx(PGoauthBearerRequestV2 *req, struct async_ctx *actx)
 {
 	/*
 	 * In general, none of the error cases below should ever happen if we have
@@ -304,9 +299,9 @@ free_async_ctx(PGconn *conn, struct async_ctx *actx)
 		CURLMcode	err = curl_multi_remove_handle(actx->curlm, actx->curl);
 
 		if (err)
-			libpq_append_conn_error(conn,
-									"libcurl easy handle removal failed: %s",
-									curl_multi_strerror(err));
+			appendPQExpBuffer(req->error,
+							  libpq_gettext("libcurl easy handle removal failed: %s"),
+							  curl_multi_strerror(err));
 	}
 
 	if (actx->curl)
@@ -324,9 +319,9 @@ free_async_ctx(PGconn *conn, struct async_ctx *actx)
 		CURLMcode	err = curl_multi_cleanup(actx->curlm);
 
 		if (err)
-			libpq_append_conn_error(conn,
-									"libcurl multi handle cleanup failed: %s",
-									curl_multi_strerror(err));
+			appendPQExpBuffer(req->error,
+							  libpq_gettext("libcurl multi handle cleanup failed: %s"),
+							  curl_multi_strerror(err));
 	}
 
 	free_provider(&actx->provider);
@@ -341,29 +336,67 @@ free_async_ctx(PGconn *conn, struct async_ctx *actx)
 	if (actx->timerfd >= 0)
 		close(actx->timerfd);
 
+	free(actx->client_id);
+	free(actx->client_secret);
+
 	free(actx);
 }
 
 /*
- * Release resources used for the asynchronous exchange and disconnect the
- * altsock.
- *
- * This is called either at the end of a successful authentication, or during
- * pqDropConnection(), so we won't leak resources even if PQconnectPoll() never
- * calls us back.
+ * Release resources used for the asynchronous exchange.
  */
-void
-pg_fe_cleanup_oauth_flow(PGconn *conn)
+static void
+pg_fe_cleanup_oauth_flow(PGconn *conn, PGoauthBearerRequest *request)
 {
-	fe_oauth_state *state = conn_sasl_state(conn);
+	struct async_ctx *actx = request->user;
+
+	/* request->cleanup is only set after actx has been allocated. */
+	Assert(actx);
 
-	if (state->async_ctx)
+	free_async_ctx((PGoauthBearerRequestV2 *) request, actx);
+	request->user = NULL;
+
+	/* libpq has made its own copy of the token; clear ours now. */
+	if (request->token)
 	{
-		free_async_ctx(conn, state->async_ctx);
-		state->async_ctx = NULL;
+		explicit_bzero(request->token, strlen(request->token));
+		free(request->token);
+		request->token = NULL;
 	}
+}
+
+/*
+ * Builds an error message from actx and appends it to req->error.
+ */
+static void
+append_actx_error(PGoauthBearerRequestV2 *req, struct async_ctx *actx)
+{
+	PQExpBuffer errbuf = req->error;
+
+	/*
+	 * Assemble the three parts of our error: context, body, and detail. See
+	 * also the documentation for struct async_ctx.
+	 */
+	if (actx->errctx)
+		appendPQExpBuffer(errbuf, "%s: ", actx->errctx);
+
+	if (PQExpBufferDataBroken(actx->errbuf))
+		appendPQExpBufferStr(errbuf, libpq_gettext("out of memory"));
+	else
+		appendPQExpBufferStr(errbuf, actx->errbuf.data);
+
+	if (actx->curl_err[0])
+	{
+		appendPQExpBuffer(errbuf, " (libcurl: %s)", actx->curl_err);
 
-	set_conn_altsock(conn, PGINVALID_SOCKET);
+		/* Sometimes libcurl adds a newline to the error buffer. :( */
+		if (errbuf->len >= 2 && errbuf->data[errbuf->len - 2] == '\n')
+		{
+			errbuf->data[errbuf->len - 2] = ')';
+			errbuf->data[errbuf->len - 1] = '\0';
+			errbuf->len--;
+		}
+	}
 }
 
 /*
@@ -2199,7 +2232,7 @@ static bool
 check_issuer(struct async_ctx *actx, PGconn *conn)
 {
 	const struct provider *provider = &actx->provider;
-	const char *oauth_issuer_id = conn_oauth_issuer_id(conn);
+	const char *oauth_issuer_id = actx->issuer_id;
 
 	Assert(oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
 	Assert(provider->issuer);	/* ensured by parse_provider() */
@@ -2302,8 +2335,8 @@ check_for_device_flow(struct async_ctx *actx)
 static bool
 add_client_identification(struct async_ctx *actx, PQExpBuffer reqbody, PGconn *conn)
 {
-	const char *oauth_client_id = conn_oauth_client_id(conn);
-	const char *oauth_client_secret = conn_oauth_client_secret(conn);
+	const char *oauth_client_id = actx->client_id;
+	const char *oauth_client_secret = actx->client_secret;
 
 	bool		success = false;
 	char	   *username = NULL;
@@ -2386,11 +2419,10 @@ cleanup:
 static bool
 start_device_authz(struct async_ctx *actx, PGconn *conn)
 {
-	const char *oauth_scope = conn_oauth_scope(conn);
+	const char *oauth_scope = actx->scope;
 	const char *device_authz_uri = actx->provider.device_authorization_endpoint;
 	PQExpBuffer work_buffer = &actx->work_data;
 
-	Assert(conn_oauth_client_id(conn)); /* ensured by setup_oauth_parameters() */
 	Assert(device_authz_uri);	/* ensured by check_for_device_flow() */
 
 	/* Construct our request body. */
@@ -2478,7 +2510,6 @@ start_token_request(struct async_ctx *actx, PGconn *conn)
 	const char *device_code = actx->authz.device_code;
 	PQExpBuffer work_buffer = &actx->work_data;
 
-	Assert(conn_oauth_client_id(conn)); /* ensured by setup_oauth_parameters() */
 	Assert(token_uri);			/* ensured by parse_provider() */
 	Assert(device_code);		/* ensured by parse_device_authz() */
 
@@ -2657,7 +2688,7 @@ prompt_user(struct async_ctx *actx, PGconn *conn)
  * function will not try to reinitialize Curl on successive calls.
  */
 static bool
-initialize_curl(PGconn *conn)
+initialize_curl(PGoauthBearerRequestV2 *req)
 {
 	/*
 	 * Don't let the compiler play tricks with this variable. In the
@@ -2691,8 +2722,8 @@ initialize_curl(PGconn *conn)
 		goto done;
 	else if (init_successful == PG_BOOL_NO)
 	{
-		libpq_append_conn_error(conn,
-								"curl_global_init previously failed during OAuth setup");
+		appendPQExpBufferStr(req->error,
+							 libpq_gettext("curl_global_init previously failed during OAuth setup"));
 		goto done;
 	}
 
@@ -2710,8 +2741,8 @@ initialize_curl(PGconn *conn)
 	 */
 	if (curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32) != CURLE_OK)
 	{
-		libpq_append_conn_error(conn,
-								"curl_global_init failed during OAuth setup");
+		appendPQExpBufferStr(req->error,
+							 libpq_gettext("curl_global_init failed during OAuth setup"));
 		init_successful = PG_BOOL_NO;
 		goto done;
 	}
@@ -2732,11 +2763,12 @@ initialize_curl(PGconn *conn)
 		 * In a downgrade situation, the damage is already done. Curl global
 		 * state may be corrupted. Be noisy.
 		 */
-		libpq_append_conn_error(conn, "libcurl is no longer thread-safe\n"
-								"\tCurl initialization was reported thread-safe when libpq\n"
-								"\twas compiled, but the currently installed version of\n"
-								"\tlibcurl reports that it is not. Recompile libpq against\n"
-								"\tthe installed version of libcurl.");
+		appendPQExpBufferStr(req->error,
+							 libpq_gettext("libcurl is no longer thread-safe\n"
+										   "\tCurl initialization was reported thread-safe when libpq\n"
+										   "\twas compiled, but the currently installed version of\n"
+										   "\tlibcurl reports that it is not. Recompile libpq against\n"
+										   "\tthe installed version of libcurl."));
 		init_successful = PG_BOOL_NO;
 		goto done;
 	}
@@ -2766,54 +2798,16 @@ done:
  * provider.
  */
 static PostgresPollingStatusType
-pg_fe_run_oauth_flow_impl(PGconn *conn)
+pg_fe_run_oauth_flow_impl(PGconn *conn, PGoauthBearerRequestV2 *request,
+						  int *altsock)
 {
-	fe_oauth_state *state = conn_sasl_state(conn);
-	struct async_ctx *actx;
+	struct async_ctx *actx = request->v1.user;
 	char	   *oauth_token = NULL;
-	PQExpBuffer errbuf;
-
-	if (!initialize_curl(conn))
-		return PGRES_POLLING_FAILED;
-
-	if (!state->async_ctx)
-	{
-		/*
-		 * Create our asynchronous state, and hook it into the upper-level
-		 * OAuth state immediately, so any failures below won't leak the
-		 * context allocation.
-		 */
-		actx = calloc(1, sizeof(*actx));
-		if (!actx)
-		{
-			libpq_append_conn_error(conn, "out of memory");
-			return PGRES_POLLING_FAILED;
-		}
-
-		actx->mux = PGINVALID_SOCKET;
-		actx->timerfd = -1;
-
-		/* Should we enable unsafe features? */
-		actx->debugging = oauth_unsafe_debugging_enabled();
-
-		state->async_ctx = actx;
-
-		initPQExpBuffer(&actx->work_data);
-		initPQExpBuffer(&actx->errbuf);
-
-		if (!setup_multiplexer(actx))
-			goto error_return;
-
-		if (!setup_curl_handles(actx))
-			goto error_return;
-	}
-
-	actx = state->async_ctx;
 
 	do
 	{
 		/* By default, the multiplexer is the altsock. Reassign as desired. */
-		set_conn_altsock(conn, actx->mux);
+		*altsock = actx->mux;
 
 		switch (actx->step)
 		{
@@ -2878,7 +2872,7 @@ pg_fe_run_oauth_flow_impl(PGconn *conn)
 
 					if (!expired)
 					{
-						set_conn_altsock(conn, actx->timerfd);
+						*altsock = actx->timerfd;
 						return PGRES_POLLING_READING;
 					}
 
@@ -2895,7 +2889,7 @@ pg_fe_run_oauth_flow_impl(PGconn *conn)
 		{
 			case OAUTH_STEP_INIT:
 				actx->errctx = libpq_gettext("failed to fetch OpenID discovery document");
-				if (!start_discovery(actx, conn_oauth_discovery_uri(conn)))
+				if (!start_discovery(actx, actx->discovery_uri))
 					goto error_return;
 
 				actx->step = OAUTH_STEP_DISCOVERY;
@@ -2935,10 +2929,10 @@ pg_fe_run_oauth_flow_impl(PGconn *conn)
 					goto error_return;
 
 				/*
-				 * Hook any oauth_token into the PGconn immediately so that
-				 * the allocation isn't lost in case of an error.
+				 * Hook any oauth_token into the request struct immediately so
+				 * that the allocation isn't lost in case of an error.
 				 */
-				set_conn_oauth_token(conn, oauth_token);
+				request->v1.token = oauth_token;
 
 				if (!actx->user_prompted)
 				{
@@ -2967,7 +2961,7 @@ pg_fe_run_oauth_flow_impl(PGconn *conn)
 				 * the client wait directly on the timerfd rather than the
 				 * multiplexer.
 				 */
-				set_conn_altsock(conn, actx->timerfd);
+				*altsock = actx->timerfd;
 
 				actx->step = OAUTH_STEP_WAIT_INTERVAL;
 				actx->running = 1;
@@ -2993,48 +2987,21 @@ pg_fe_run_oauth_flow_impl(PGconn *conn)
 	return oauth_token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
 
 error_return:
-	errbuf = conn_errorMessage(conn);
-
-	/*
-	 * Assemble the three parts of our error: context, body, and detail. See
-	 * also the documentation for struct async_ctx.
-	 */
-	if (actx->errctx)
-		appendPQExpBuffer(errbuf, "%s: ", actx->errctx);
-
-	if (PQExpBufferDataBroken(actx->errbuf))
-		appendPQExpBufferStr(errbuf, libpq_gettext("out of memory"));
-	else
-		appendPQExpBufferStr(errbuf, actx->errbuf.data);
-
-	if (actx->curl_err[0])
-	{
-		appendPQExpBuffer(errbuf, " (libcurl: %s)", actx->curl_err);
-
-		/* Sometimes libcurl adds a newline to the error buffer. :( */
-		if (errbuf->len >= 2 && errbuf->data[errbuf->len - 2] == '\n')
-		{
-			errbuf->data[errbuf->len - 2] = ')';
-			errbuf->data[errbuf->len - 1] = '\0';
-			errbuf->len--;
-		}
-	}
-
-	appendPQExpBufferChar(errbuf, '\n');
+	append_actx_error(request, actx);
 
 	return PGRES_POLLING_FAILED;
 }
 
 /*
- * The top-level entry point. This is a convenient place to put necessary
- * wrapper logic before handing off to the true implementation, above.
+ * The top-level entry point for the flow. This is a convenient place to put
+ * necessary wrapper logic before handing off to the true implementation, above.
  */
-PostgresPollingStatusType
-pg_fe_run_oauth_flow(PGconn *conn)
+static PostgresPollingStatusType
+pg_fe_run_oauth_flow(PGconn *conn, struct PGoauthBearerRequest *request,
+					 int *altsock)
 {
 	PostgresPollingStatusType result;
-	fe_oauth_state *state = conn_sasl_state(conn);
-	struct async_ctx *actx;
+	struct async_ctx *actx = request->user;
 #ifndef WIN32
 	sigset_t	osigset;
 	bool		sigpipe_pending;
@@ -3061,20 +3028,16 @@ pg_fe_run_oauth_flow(PGconn *conn)
 	masked = (pq_block_sigpipe(&osigset, &sigpipe_pending) == 0);
 #endif
 
-	result = pg_fe_run_oauth_flow_impl(conn);
+	result = pg_fe_run_oauth_flow_impl(conn,
+									   (PGoauthBearerRequestV2 *) request,
+									   altsock);
 
 	/*
 	 * To assist with finding bugs in comb_multiplexer() and
 	 * drain_timer_events(), when we're in debug mode, track the total number
 	 * of calls to this function and print that at the end of the flow.
-	 *
-	 * Be careful that state->async_ctx could be NULL if early initialization
-	 * fails during the first call.
 	 */
-	actx = state->async_ctx;
-	Assert(actx || result == PGRES_POLLING_FAILED);
-
-	if (actx && actx->debugging)
+	if (actx->debugging)
 	{
 		actx->dbg_num_calls++;
 		if (result == PGRES_POLLING_OK || result == PGRES_POLLING_FAILED)
@@ -3095,3 +3058,103 @@ pg_fe_run_oauth_flow(PGconn *conn)
 
 	return result;
 }
+
+/*
+ * Callback registration for OAUTHBEARER. libpq calls this once per OAuth
+ * connection.
+ */
+int
+pg_start_oauthbearer(PGconn *conn, PGoauthBearerRequestV2 *request)
+{
+	struct async_ctx *actx;
+
+	if (!initialize_curl(request))	/* TODO move to init function? */
+		return -1;
+
+	/*
+	 * Create our asynchronous state, and hook it into the upper-level OAuth
+	 * state immediately, so any failures below won't leak the context
+	 * allocation.
+	 */
+	actx = calloc(1, sizeof(*actx));
+	if (!actx)
+		goto oom;
+
+	actx->mux = PGINVALID_SOCKET;
+	actx->timerfd = -1;
+
+	/*
+	 * Now we have a valid (but still useless) actx, so we can fill in the
+	 * request object. From this point onward, failures will result in a call
+	 * to pg_fe_cleanup_oauth_flow(). Further cleanup logic belongs there.
+	 */
+	request->v1.async = pg_fe_run_oauth_flow;
+	request->v1.cleanup = pg_fe_cleanup_oauth_flow;
+	request->v1.user = actx;
+
+	/*
+	 * Now finish filling in the actx.
+	 */
+
+	/* Should we enable unsafe features? */
+	actx->debugging = oauth_unsafe_debugging_enabled();
+
+	initPQExpBuffer(&actx->work_data);
+	initPQExpBuffer(&actx->errbuf);
+
+	/* Pull relevant connection options. */
+	{
+		PQconninfoOption *conninfo = PQconninfo(conn);
+
+		if (!conninfo)
+			goto oom;
+
+		for (PQconninfoOption *opt = conninfo; opt->keyword; opt++)
+		{
+			if (!opt->val)
+				continue;		/* simplifies the strdup logic below */
+
+			if (strcmp(opt->keyword, "oauth_client_id") == 0)
+			{
+				actx->client_id = strdup(opt->val);
+				if (!actx->client_id)
+					goto oom;
+			}
+			else if (strcmp(opt->keyword, "oauth_client_secret") == 0)
+			{
+				actx->client_secret = strdup(opt->val);
+				if (!actx->client_secret)
+					goto oom;
+			}
+		}
+
+		PQconninfoFree(conninfo);
+	}
+
+	actx->discovery_uri = request->v1.openid_configuration;
+	actx->issuer_id = request->issuer;
+	actx->scope = request->v1.scope;
+
+	Assert(actx->client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(actx->issuer_id);	/* ensured by setup_oauth_parameters() */
+	Assert(actx->discovery_uri);	/* ensured by oauth_exchange() */
+
+	if (!setup_multiplexer(actx))
+	{
+		append_actx_error(request, actx);
+		return -1;
+	}
+
+	if (!setup_curl_handles(actx))
+	{
+		append_actx_error(request, actx);
+		return -1;
+	}
+
+	return 0;
+
+oom:
+	appendPQExpBufferStr(request->error,
+						 libpq_gettext("out of memory"));
+	return -1;
+}
diff --git a/src/interfaces/libpq-oauth/oauth-utils.c b/src/interfaces/libpq-oauth/oauth-utils.c
index 45fdc7579f2..f1d30e01cf0 100644
--- a/src/interfaces/libpq-oauth/oauth-utils.c
+++ b/src/interfaces/libpq-oauth/oauth-utils.c
@@ -35,17 +35,6 @@
 pgthreadlock_t pg_g_threadlock;
 static libpq_gettext_func libpq_gettext_impl;
 
-conn_errorMessage_func conn_errorMessage;
-conn_oauth_client_id_func conn_oauth_client_id;
-conn_oauth_client_secret_func conn_oauth_client_secret;
-conn_oauth_discovery_uri_func conn_oauth_discovery_uri;
-conn_oauth_issuer_id_func conn_oauth_issuer_id;
-conn_oauth_scope_func conn_oauth_scope;
-conn_sasl_state_func conn_sasl_state;
-
-set_conn_altsock_func set_conn_altsock;
-set_conn_oauth_token_func set_conn_oauth_token;
-
 /*-
  * Initializes libpq-oauth by setting necessary callbacks.
  *
@@ -63,57 +52,10 @@ set_conn_oauth_token_func set_conn_oauth_token;
  */
 void
 libpq_oauth_init(pgthreadlock_t threadlock_impl,
-				 libpq_gettext_func gettext_impl,
-				 conn_errorMessage_func errmsg_impl,
-				 conn_oauth_client_id_func clientid_impl,
-				 conn_oauth_client_secret_func clientsecret_impl,
-				 conn_oauth_discovery_uri_func discoveryuri_impl,
-				 conn_oauth_issuer_id_func issuerid_impl,
-				 conn_oauth_scope_func scope_impl,
-				 conn_sasl_state_func saslstate_impl,
-				 set_conn_altsock_func setaltsock_impl,
-				 set_conn_oauth_token_func settoken_impl)
+				 libpq_gettext_func gettext_impl)
 {
 	pg_g_threadlock = threadlock_impl;
 	libpq_gettext_impl = gettext_impl;
-	conn_errorMessage = errmsg_impl;
-	conn_oauth_client_id = clientid_impl;
-	conn_oauth_client_secret = clientsecret_impl;
-	conn_oauth_discovery_uri = discoveryuri_impl;
-	conn_oauth_issuer_id = issuerid_impl;
-	conn_oauth_scope = scope_impl;
-	conn_sasl_state = saslstate_impl;
-	set_conn_altsock = setaltsock_impl;
-	set_conn_oauth_token = settoken_impl;
-}
-
-/*
- * Append a formatted string to the error message buffer of the given
- * connection, after translating it.  This is a copy of libpq's internal API.
- */
-void
-libpq_append_conn_error(PGconn *conn, const char *fmt,...)
-{
-	int			save_errno = errno;
-	bool		done;
-	va_list		args;
-	PQExpBuffer errorMessage = conn_errorMessage(conn);
-
-	Assert(fmt[strlen(fmt) - 1] != '\n');
-
-	if (PQExpBufferBroken(errorMessage))
-		return;					/* already failed */
-
-	/* Loop in case we have to retry after enlarging the buffer. */
-	do
-	{
-		errno = save_errno;
-		va_start(args, fmt);
-		done = appendPQExpBufferVA(errorMessage, libpq_gettext(fmt), args);
-		va_end(args);
-	} while (!done);
-
-	appendPQExpBufferChar(errorMessage, '\n');
 }
 
 #ifdef ENABLE_NLS
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
index f94bf31b2ea..da6a2c4a2f7 100644
--- a/src/interfaces/libpq/fe-auth-oauth.c
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -78,7 +78,7 @@ oauth_init(PGconn *conn, const char *password,
  * This handles only mechanism state tied to the connection lifetime; state
  * stored in state->async_ctx is freed up either immediately after the
  * authentication handshake succeeds, or before the mechanism is cleaned up on
- * failure. See pg_fe_cleanup_oauth_flow() and cleanup_user_oauth_flow().
+ * failure. See pg_fe_cleanup_oauth_flow() and cleanup_oauth_flow().
  */
 static void
 oauth_free(void *opaq)
@@ -680,8 +680,9 @@ cleanup:
  * it's added to conn->errorMessage here.
  */
 static void
-report_user_flow_error(PGconn *conn, const PGoauthBearerRequestV2 *request)
+report_flow_error(PGconn *conn, const PGoauthBearerRequestV2 *request)
 {
+	fe_oauth_state *state = conn->sasl_state;
 	const char *errmsg = NULL;
 
 	if (PQExpBufferBroken(request->error))
@@ -689,28 +690,49 @@ report_user_flow_error(PGconn *conn, const PGoauthBearerRequestV2 *request)
 	else if (request->error->len)
 		errmsg = request->error->data;
 
-	appendPQExpBufferStr(&conn->errorMessage,
-						 libpq_gettext("user-defined OAuth flow failed"));
-
-	if (errmsg)
+	/*
+	 * User-defined flows are called out explicitly so that the user knows who
+	 * to blame. Builtin flows don't need that extra message length; we expect
+	 * them to always fill in request->error on failure anyway.
+	 */
+	if (state->builtin)
 	{
-		appendPQExpBufferStr(&conn->errorMessage, ": ");
+		if (!errmsg)
+		{
+			/*
+			 * Don't turn a bug here into a crash in production, but don't
+			 * bother translating either.
+			 */
+			Assert(false);
+			errmsg = "builtin flow failed but did not provide an error message";
+		}
+
 		appendPQExpBufferStr(&conn->errorMessage, errmsg);
 	}
+	else
+	{
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext("user-defined OAuth flow failed"));
+		if (errmsg)
+		{
+			appendPQExpBufferStr(&conn->errorMessage, ": ");
+			appendPQExpBufferStr(&conn->errorMessage, errmsg);
+		}
+	}
 
 	appendPQExpBufferChar(&conn->errorMessage, '\n');
 }
 
 /*
- * Callback implementation of conn->async_auth() for a user-defined OAuth flow.
- * Delegates the retrieval of the token to the application's async callback.
+ * Callback implementation of conn->async_auth() for OAuth flows. Delegates the
+ * retrieval of the token to the PGoauthBearerRequestV2.async() callback.
  *
- * This will be called multiple times as needed; the application is responsible
- * for setting an altsock to signal and returning the correct PGRES_POLLING_*
+ * This will be called multiple times as needed; the callback is responsible for
+ * setting an altsock to signal and returning the correct PGRES_POLLING_*
  * statuses for use by PQconnectPoll().
  */
 static PostgresPollingStatusType
-run_user_oauth_flow(PGconn *conn)
+run_oauth_flow(PGconn *conn)
 {
 	fe_oauth_state *state = conn->sasl_state;
 	PGoauthBearerRequestV2 *request = state->async_ctx;
@@ -718,6 +740,7 @@ run_user_oauth_flow(PGconn *conn)
 
 	if (!request->v1.async)
 	{
+		Assert(!state->builtin);	/* be very noisy if our code does this */
 		libpq_append_conn_error(conn,
 								"user-defined OAuth flow provided neither a token nor an async callback");
 		return PGRES_POLLING_FAILED;
@@ -729,7 +752,7 @@ run_user_oauth_flow(PGconn *conn)
 
 	if (status == PGRES_POLLING_FAILED)
 	{
-		report_user_flow_error(conn, request);
+		report_flow_error(conn, request);
 		return status;
 	}
 	else if (status == PGRES_POLLING_OK)
@@ -741,6 +764,7 @@ run_user_oauth_flow(PGconn *conn)
 		 */
 		if (!request->v1.token)
 		{
+			Assert(!state->builtin);
 			libpq_append_conn_error(conn,
 									"user-defined OAuth flow did not provide a token");
 			return PGRES_POLLING_FAILED;
@@ -759,6 +783,7 @@ run_user_oauth_flow(PGconn *conn)
 	/* The hook wants the client to poll the altsock. Make sure it set one. */
 	if (conn->altsock == PGINVALID_SOCKET)
 	{
+		Assert(!state->builtin);
 		libpq_append_conn_error(conn,
 								"user-defined OAuth flow did not provide a socket for polling");
 		return PGRES_POLLING_FAILED;
@@ -768,12 +793,16 @@ run_user_oauth_flow(PGconn *conn)
 }
 
 /*
- * Cleanup callback for the async user flow. Delegates most of its job to
+ * Cleanup callback for the async flow. Delegates most of its job to
  * PGoauthBearerRequestV2.cleanup(), then disconnects the altsock and frees the
  * request itself.
+ *
+ * This is called either at the end of a successful authentication, or during
+ * pqDropConnection(), so we won't leak resources even if PQconnectPoll() never
+ * calls us back.
  */
 static void
-cleanup_user_oauth_flow(PGconn *conn)
+cleanup_oauth_flow(PGconn *conn)
 {
 	fe_oauth_state *state = conn->sasl_state;
 	PGoauthBearerRequestV2 *request = state->async_ctx;
@@ -794,12 +823,16 @@ cleanup_user_oauth_flow(PGconn *conn)
  *
  * There are three potential implementations of use_builtin_flow:
  *
- * 1) If the OAuth client is disabled at configuration time, return false.
+ * 1) If the OAuth client is disabled at configuration time, return zero.
  *    Dependent clients must provide their own flow.
  * 2) If the OAuth client is enabled and USE_DYNAMIC_OAUTH is defined, dlopen()
  *    the libpq-oauth plugin and use its implementation.
  * 3) Otherwise, use flow callbacks that are statically linked into the
  *    executable.
+ *
+ * For caller convenience, the return value follows the convention of
+ * PQauthDataHook: zero means no implementation is provided, negative indicates
+ * failure, and positive indicates success.
  */
 
 #if !defined(USE_LIBCURL)
@@ -808,10 +841,10 @@ cleanup_user_oauth_flow(PGconn *conn)
  * This configuration doesn't support the builtin flow.
  */
 
-bool
-use_builtin_flow(PGconn *conn, fe_oauth_state *state)
+static int
+use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *request)
 {
-	return false;
+	return 0;
 }
 
 #elif defined(USE_DYNAMIC_OAUTH)
@@ -822,36 +855,6 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state)
 
 typedef char *(*libpq_gettext_func) (const char *msgid);
 
-/*
- * Define accessor/mutator shims to inject into libpq-oauth, so that it doesn't
- * depend on the offsets within PGconn. (These have changed during minor version
- * updates in the past.)
- */
-
-#define DEFINE_GETTER(TYPE, MEMBER) \
-	typedef TYPE (*conn_ ## MEMBER ## _func) (PGconn *conn); \
-	static TYPE conn_ ## MEMBER(PGconn *conn) { return conn->MEMBER; }
-
-/* Like DEFINE_GETTER, but returns a pointer to the member. */
-#define DEFINE_GETTER_P(TYPE, MEMBER) \
-	typedef TYPE (*conn_ ## MEMBER ## _func) (PGconn *conn); \
-	static TYPE conn_ ## MEMBER(PGconn *conn) { return &conn->MEMBER; }
-
-#define DEFINE_SETTER(TYPE, MEMBER) \
-	typedef void (*set_conn_ ## MEMBER ## _func) (PGconn *conn, TYPE val); \
-	static void set_conn_ ## MEMBER(PGconn *conn, TYPE val) { conn->MEMBER = val; }
-
-DEFINE_GETTER_P(PQExpBuffer, errorMessage);
-DEFINE_GETTER(char *, oauth_client_id);
-DEFINE_GETTER(char *, oauth_client_secret);
-DEFINE_GETTER(char *, oauth_discovery_uri);
-DEFINE_GETTER(char *, oauth_issuer_id);
-DEFINE_GETTER(char *, oauth_scope);
-DEFINE_GETTER(fe_oauth_state *, sasl_state);
-
-DEFINE_SETTER(pgsocket, altsock);
-DEFINE_SETTER(char *, oauth_token);
-
 /*
  * Loads the libpq-oauth plugin via dlopen(), initializes it, and plugs its
  * callbacks into the connection's async auth handlers.
@@ -860,27 +863,20 @@ DEFINE_SETTER(char *, oauth_token);
  * handle the use case where the build supports loading a flow but a user does
  * not want to install it. Troubleshooting of linker/loader failures can be done
  * via PGOAUTHDEBUG.
+ *
+ * The lifetime of *request ends shortly after this call, so it must be copied
+ * to longer-lived storage.
  */
-bool
-use_builtin_flow(PGconn *conn, fe_oauth_state *state)
+static int
+use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *request)
 {
 	static bool initialized = false;
 	static pthread_mutex_t init_mutex = PTHREAD_MUTEX_INITIALIZER;
 	int			lockerr;
 
 	void		(*init) (pgthreadlock_t threadlock,
-						 libpq_gettext_func gettext_impl,
-						 conn_errorMessage_func errmsg_impl,
-						 conn_oauth_client_id_func clientid_impl,
-						 conn_oauth_client_secret_func clientsecret_impl,
-						 conn_oauth_discovery_uri_func discoveryuri_impl,
-						 conn_oauth_issuer_id_func issuerid_impl,
-						 conn_oauth_scope_func scope_impl,
-						 conn_sasl_state_func saslstate_impl,
-						 set_conn_altsock_func setaltsock_impl,
-						 set_conn_oauth_token_func settoken_impl);
-	PostgresPollingStatusType (*flow) (PGconn *conn);
-	void		(*cleanup) (PGconn *conn);
+						 libpq_gettext_func gettext_impl);
+	int			(*start_flow) (PGconn *conn, PGoauthBearerRequestV2 *request);
 
 	/*
 	 * On macOS only, load the module using its absolute install path; the
@@ -893,9 +889,9 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state)
 	 */
 	const char *const module_name =
 #if defined(__darwin__)
-		LIBDIR "/libpq-oauth-" PG_MAJORVERSION DLSUFFIX;
+		LIBDIR "/libpq-oauth" DLSUFFIX;
 #else
-		"libpq-oauth-" PG_MAJORVERSION DLSUFFIX;
+		"libpq-oauth" DLSUFFIX;
 #endif
 
 	state->builtin_flow = dlopen(module_name, RTLD_NOW | RTLD_LOCAL);
@@ -911,12 +907,11 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state)
 		if (oauth_unsafe_debugging_enabled())
 			fprintf(stderr, "failed dlopen for libpq-oauth: %s\n", dlerror());
 
-		return false;
+		return 0;
 	}
 
 	if ((init = dlsym(state->builtin_flow, "libpq_oauth_init")) == NULL
-		|| (flow = dlsym(state->builtin_flow, "pg_fe_run_oauth_flow")) == NULL
-		|| (cleanup = dlsym(state->builtin_flow, "pg_fe_cleanup_oauth_flow")) == NULL)
+		|| (start_flow = dlsym(state->builtin_flow, "pg_start_oauthbearer")) == NULL)
 	{
 		/*
 		 * This is more of an error condition than the one above, but due to
@@ -926,7 +921,7 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state)
 			fprintf(stderr, "failed dlsym for libpq-oauth: %s\n", dlerror());
 
 		dlclose(state->builtin_flow);
-		return false;
+		return 0;
 	}
 
 	/*
@@ -946,56 +941,37 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state)
 		Assert(false);
 
 		libpq_append_conn_error(conn, "failed to lock mutex (%d)", lockerr);
-		return false;
+		return 0;
 	}
 
 	if (!initialized)
 	{
 		init(pg_g_threadlock,
 #ifdef ENABLE_NLS
-			 libpq_gettext,
+			 libpq_gettext
 #else
-			 NULL,
+			 NULL
 #endif
-			 conn_errorMessage,
-			 conn_oauth_client_id,
-			 conn_oauth_client_secret,
-			 conn_oauth_discovery_uri,
-			 conn_oauth_issuer_id,
-			 conn_oauth_scope,
-			 conn_sasl_state,
-			 set_conn_altsock,
-			 set_conn_oauth_token);
+			);
 
 		initialized = true;
 	}
 
 	pthread_mutex_unlock(&init_mutex);
 
-	/* Set our asynchronous callbacks. */
-	conn->async_auth = flow;
-	conn->cleanup_async_auth = cleanup;
-
-	return true;
+	return (start_flow(conn, request) == 0) ? 1 : -1;
 }
 
 #else
 
 /*
- * Use the builtin flow in libpq-oauth.a (see libpq-oauth/oauth-curl.h).
+ * For static builds, we can just call pg_start_oauthbearer() directly. It's
+ * provided by libpq-oauth.a.
  */
-
-extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
-extern void pg_fe_cleanup_oauth_flow(PGconn *conn);
-
-bool
-use_builtin_flow(PGconn *conn, fe_oauth_state *state)
+static int
+use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *request)
 {
-	/* Set our asynchronous callbacks. */
-	conn->async_auth = pg_fe_run_oauth_flow;
-	conn->cleanup_async_auth = pg_fe_cleanup_oauth_flow;
-
-	return true;
+	return (pg_start_oauthbearer(conn, request) == 0) ? 1 : -1;
 }
 
 #endif							/* USE_LIBCURL */
@@ -1008,11 +984,11 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state)
  * If the application has registered a custom flow handler using
  * PQAUTHDATA_OAUTH_BEARER_TOKEN[_V2], it may either return a token immediately
  * (e.g. if it has one cached for immediate use), or set up for a series of
- * asynchronous callbacks which will be managed by run_user_oauth_flow().
+ * asynchronous callbacks which will be managed by run_oauth_flow().
  *
  * If the default handler is used instead, a Device Authorization flow is used
- * for the connection if support has been compiled in. (See
- * fe-auth-oauth-curl.c for implementation details.)
+ * for the connection if support has been compiled in. (See oauth-curl.c for
+ * implementation details.)
  *
  * If neither a custom handler nor the builtin flow is available, the connection
  * fails here.
@@ -1041,11 +1017,17 @@ setup_token_request(PGconn *conn, fe_oauth_state *state)
 
 	/*
 	 * The client may have overridden the OAuth flow. Try the v2 hook first,
-	 * then fall back to the v1 implementation.
+	 * then fall back to the v1 implementation. If neither is available, try
+	 * the builtin flow.
 	 */
 	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN_V2, conn, &request);
 	if (res == 0)
 		res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
+	if (res == 0)
+	{
+		state->builtin = true;
+		res = use_builtin_flow(conn, state, &request);
+	}
 
 	if (res > 0)
 	{
@@ -1081,16 +1063,16 @@ setup_token_request(PGconn *conn, fe_oauth_state *state)
 
 		*request_copy = request;
 
-		conn->async_auth = run_user_oauth_flow;
-		conn->cleanup_async_auth = cleanup_user_oauth_flow;
+		conn->async_auth = run_oauth_flow;
+		conn->cleanup_async_auth = cleanup_oauth_flow;
 		state->async_ctx = request_copy;
 	}
 	else if (res < 0)
 	{
-		report_user_flow_error(conn, &request);
+		report_flow_error(conn, &request);
 		goto fail;
 	}
-	else if (!use_builtin_flow(conn, state))
+	else
 	{
 		libpq_append_conn_error(conn, "no OAuth flows are available (try installing the libpq-oauth package)");
 		goto fail;
-- 
2.34.1

v2-0006-libpq-oauth-Never-link-against-libpq-s-encoding-f.patchapplication/octet-stream; name=v2-0006-libpq-oauth-Never-link-against-libpq-s-encoding-f.patchDownload
From 41132991bb4df9099eefa2b419da7462c0a1b369 Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Wed, 3 Dec 2025 09:53:44 -0800
Subject: [PATCH v2 6/7] libpq-oauth: Never link against libpq's encoding
 functions

Now that libpq-oauth doesn't have to match the major version of libpq,
some things in pg_wchar.h are technically unsafe for us to use. (See
b6c7cfac8 for a fuller discussion.) This is unlikely to be a problem --
we only care about UTF-8 in the context of OAuth right now -- but if
anyone did introduce a way to hit it, it'd be extremely difficult to
debug or reproduce, and it'd be a potential security vulnerability to
boot.

Define USE_PRIVATE_ENCODING_FUNCS so that anyone who tries to add a
dependency on the exported APIs will simply fail to link the shared
module.

Reviewed-by: Chao Li <li.evan.chao@gmail.com>
Discussion: https://postgr.es/m/CAOYmi%2BmrGg%2Bn_X2MOLgeWcj3v_M00gR8uz_D7mM8z%3DdX1JYVbg%40mail.gmail.com
---
 src/interfaces/libpq-oauth/meson.build | 10 +++++++++-
 src/interfaces/libpq-oauth/Makefile    | 11 +++++++++--
 2 files changed, 18 insertions(+), 3 deletions(-)

diff --git a/src/interfaces/libpq-oauth/meson.build b/src/interfaces/libpq-oauth/meson.build
index 11199100b28..b755990490d 100644
--- a/src/interfaces/libpq-oauth/meson.build
+++ b/src/interfaces/libpq-oauth/meson.build
@@ -12,7 +12,15 @@ libpq_oauth_sources = files(
 libpq_oauth_so_sources = files(
   'oauth-utils.c',
 )
-libpq_oauth_so_c_args = ['-DUSE_DYNAMIC_OAUTH']
+libpq_oauth_so_c_args = [
+  '-DUSE_DYNAMIC_OAUTH',
+
+  # A bit of forward-looking paranoia: don't allow anyone to accidentally depend
+  # on the encoding IDs coming from libpq. They're not guaranteed to match the
+  # IDs in use by our version of pgcommon, now that we allow the major version
+  # of libpq to differ from the major version of libpq-oauth.
+  '-DUSE_PRIVATE_ENCODING_FUNCS',
+]
 
 export_file = custom_target('libpq-oauth.exports',
   kwargs: gen_export_kwargs,
diff --git a/src/interfaces/libpq-oauth/Makefile b/src/interfaces/libpq-oauth/Makefile
index 0febb393feb..4572fe780d0 100644
--- a/src/interfaces/libpq-oauth/Makefile
+++ b/src/interfaces/libpq-oauth/Makefile
@@ -24,6 +24,14 @@ override shlib := lib$(NAME)$(DLSUFFIX)
 override CPPFLAGS := -I$(libpq_srcdir) -I$(top_builddir)/src/port $(CPPFLAGS) $(LIBCURL_CPPFLAGS)
 override CFLAGS += $(PTHREAD_CFLAGS)
 
+override CPPFLAGS_SHLIB := -DUSE_DYNAMIC_OAUTH
+
+# A bit of forward-looking paranoia: don't allow libpq-oauth.so to accidentally
+# depend on the encoding IDs coming from libpq. They're not guaranteed to match
+# the IDs in use by our version of pgcommon, now that we allow the major version
+# of libpq to differ from the major version of libpq-oauth.
+override CPPFLAGS_SHLIB += -DUSE_PRIVATE_ENCODING_FUNCS
+
 OBJS = \
 	$(WIN32RES)
 
@@ -34,8 +42,7 @@ OBJS_SHLIB = \
 	oauth-curl_shlib.o \
 	oauth-utils.o \
 
-oauth-utils.o: override CPPFLAGS += -DUSE_DYNAMIC_OAUTH
-oauth-curl_shlib.o: override CPPFLAGS_SHLIB += -DUSE_DYNAMIC_OAUTH
+oauth-utils.o: override CPPFLAGS += $(CPPFLAGS_SHLIB)
 
 # Add shlib-/stlib-specific objects.
 $(shlib): override OBJS += $(OBJS_SHLIB)
-- 
2.34.1

v2-0007-WIP-Introduce-third-party-OAuth-flow-plugins.patchapplication/octet-stream; name=v2-0007-WIP-Introduce-third-party-OAuth-flow-plugins.patchDownload
From e20b555aba57ac7e810a0b3e7821690f7e41072f Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Wed, 3 Dec 2025 15:47:23 -0800
Subject: [PATCH v2 7/7] WIP: Introduce third-party OAuth flow plugins?

This experimental commit promotes the pg_start_oauthbearer API to a
public header (libpq-oauth.h) and adds a PGOAUTHMODULE environment
variable that overrides the load path for the plugin, allowing users to
provide their own. The libpq_oauth_init function is now optional, and
will remain undocumented. (Modules that don't provide it are marked as
user-defined.)

This is a relatively small amount of implementation change, but
unfortunately the tests have a large amount of code motion to be able to
share logic between the test executable and plugin. I might need to
split that into multiple squash! commits to make it more easily
reviewable.

TODO: figure out PGDLLEXPORT, which we do not currently provide publicly
TODO: add a public init() API so that no one tries to implement
      libpq_oauth_init()?
TODO: lock down PGOAUTHMODULE as necessary to avoid introducing exciting
      new vulnerabilities
TODO: how hard would it be to support Windows here?

Discussion: https://postgr.es/m/CAOYmi%2BmrGg%2Bn_X2MOLgeWcj3v_M00gR8uz_D7mM8z%3DdX1JYVbg%40mail.gmail.com
---
 src/interfaces/libpq/meson.build              |   1 +
 src/interfaces/libpq/Makefile                 |   2 +
 src/interfaces/libpq-oauth/oauth-curl.h       |  24 --
 src/interfaces/libpq/fe-auth-oauth.h          |   2 +-
 src/interfaces/libpq/libpq-oauth.h            |  52 +++
 src/interfaces/libpq-oauth/oauth-curl.c       |   2 +-
 src/interfaces/libpq/fe-auth-oauth.c          |  81 ++--
 src/test/modules/oauth_validator/meson.build  |  15 +
 src/test/modules/oauth_validator/Makefile     |  10 +-
 .../oauth_validator/oauth_test_common.h       |  26 ++
 src/test/modules/oauth_validator/oauth_flow.c |  69 ++++
 .../oauth_validator/oauth_hook_client.c       | 319 +--------------
 .../oauth_validator/oauth_test_common.c       | 374 ++++++++++++++++++
 .../modules/oauth_validator/t/002_client.pl   |  41 +-
 14 files changed, 649 insertions(+), 369 deletions(-)
 delete mode 100644 src/interfaces/libpq-oauth/oauth-curl.h
 create mode 100644 src/interfaces/libpq/libpq-oauth.h
 create mode 100644 src/test/modules/oauth_validator/oauth_test_common.h
 create mode 100644 src/test/modules/oauth_validator/oauth_flow.c
 create mode 100644 src/test/modules/oauth_validator/oauth_test_common.c

diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index b259c998fa2..ac71548b059 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -128,6 +128,7 @@ pkgconfig.generate(
 install_headers(
   'libpq-fe.h',
   'libpq-events.h',
+  'libpq-oauth.h',
 )
 
 install_headers(
diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile
index 9fe321147fc..1d0cd22f6f0 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -155,6 +155,7 @@ $(top_builddir)/src/port/pg_config_paths.h:
 install: all installdirs install-lib
 	$(INSTALL_DATA) $(srcdir)/libpq-fe.h '$(DESTDIR)$(includedir)'
 	$(INSTALL_DATA) $(srcdir)/libpq-events.h '$(DESTDIR)$(includedir)'
+	$(INSTALL_DATA) $(srcdir)/libpq-oauth.h '$(DESTDIR)$(includedir)'
 	$(INSTALL_DATA) $(srcdir)/libpq-int.h '$(DESTDIR)$(includedir_internal)'
 	$(INSTALL_DATA) $(srcdir)/fe-auth-sasl.h '$(DESTDIR)$(includedir_internal)'
 	$(INSTALL_DATA) $(srcdir)/pqexpbuffer.h '$(DESTDIR)$(includedir_internal)'
@@ -177,6 +178,7 @@ installdirs: installdirs-lib
 uninstall: uninstall-lib
 	rm -f '$(DESTDIR)$(includedir)/libpq-fe.h'
 	rm -f '$(DESTDIR)$(includedir)/libpq-events.h'
+	rm -f '$(DESTDIR)$(includedir)/libpq-oauth.h'
 	rm -f '$(DESTDIR)$(includedir_internal)/libpq-int.h'
 	rm -f '$(DESTDIR)$(includedir_internal)/fe-auth-sasl.h'
 	rm -f '$(DESTDIR)$(includedir_internal)/pqexpbuffer.h'
diff --git a/src/interfaces/libpq-oauth/oauth-curl.h b/src/interfaces/libpq-oauth/oauth-curl.h
deleted file mode 100644
index 47704689586..00000000000
--- a/src/interfaces/libpq-oauth/oauth-curl.h
+++ /dev/null
@@ -1,24 +0,0 @@
-/*-------------------------------------------------------------------------
- *
- * oauth-curl.h
- *
- *	  Definitions for OAuth Device Authorization module
- *
- * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
- * Portions Copyright (c) 1994, Regents of the University of California
- *
- * src/interfaces/libpq-oauth/oauth-curl.h
- *
- *-------------------------------------------------------------------------
- */
-
-#ifndef OAUTH_CURL_H
-#define OAUTH_CURL_H
-
-#include "libpq-fe.h"
-
-/* Exported flow callback. */
-extern PGDLLEXPORT int pg_start_oauthbearer(PGconn *conn,
-											PGoauthBearerRequestV2 *request);
-
-#endif							/* OAUTH_CURL_H */
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
index b9aed879e64..30d3ff6741e 100644
--- a/src/interfaces/libpq/fe-auth-oauth.h
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -35,7 +35,7 @@ typedef struct
 	void	   *async_ctx;
 
 	bool		builtin;
-	void	   *builtin_flow;
+	void	   *flow_module;
 } fe_oauth_state;
 
 extern void pqClearOAuthToken(PGconn *conn);
diff --git a/src/interfaces/libpq/libpq-oauth.h b/src/interfaces/libpq/libpq-oauth.h
new file mode 100644
index 00000000000..2a62b330b1c
--- /dev/null
+++ b/src/interfaces/libpq/libpq-oauth.h
@@ -0,0 +1,52 @@
+/*-------------------------------------------------------------------------
+ *
+ * libpq-oauth.h
+ *	  This file contains structs and functions used by custom OAuth plugins.
+ *
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ *
+ * src/interfaces/libpq/libpq-oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef LIBPQ_OAUTH_H
+#define LIBPQ_OAUTH_H
+
+#include "libpq-fe.h"
+
+#ifdef __cplusplus
+extern "C"
+{
+#endif
+
+/* XXX can't rely on c.h, but duplicating this is asking for trouble */
+#ifndef PGDLLEXPORT
+#ifdef _WIN32
+#define PGDLLEXPORT __declspec (dllexport)
+#elif defined(__has_attribute)
+#if __has_attribute(visibility)
+#define PGDLLEXPORT __attribute__((visibility("default")))
+#else
+#define PGDLLEXPORT
+#endif
+#else
+#define PGDLLEXPORT
+#endif
+#endif
+
+/*
+ * V1 API
+ *
+ * Flow plugins must provide an implementation of this callback.
+ *
+ * TODO: provide a magic struct that allows backwards but not forwards compat?
+ */
+extern PGDLLEXPORT int pg_start_oauthbearer(PGconn *conn,
+											PGoauthBearerRequestV2 *request);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif							/* LIBPQ_OAUTH_H */
diff --git a/src/interfaces/libpq-oauth/oauth-curl.c b/src/interfaces/libpq-oauth/oauth-curl.c
index b94458c860f..587c9c882e6 100644
--- a/src/interfaces/libpq-oauth/oauth-curl.c
+++ b/src/interfaces/libpq-oauth/oauth-curl.c
@@ -29,8 +29,8 @@
 #endif
 
 #include "common/jsonapi.h"
+#include "libpq-oauth.h"
 #include "mb/pg_wchar.h"
-#include "oauth-curl.h"
 
 #ifdef USE_DYNAMIC_OAUTH
 
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
index da6a2c4a2f7..88cf1b45938 100644
--- a/src/interfaces/libpq/fe-auth-oauth.c
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -17,6 +17,8 @@
 
 #ifdef USE_DYNAMIC_OAUTH
 #include <dlfcn.h>
+#else
+#include "libpq-oauth.h"
 #endif
 
 #include "common/base64.h"
@@ -887,15 +889,33 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *re
 	 * On the other platforms, load the module using only the basename, to
 	 * rely on the runtime linker's standard search behavior.
 	 */
-	const char *const module_name =
+	const char *module_name =
 #if defined(__darwin__)
 		LIBDIR "/libpq-oauth" DLSUFFIX;
 #else
 		"libpq-oauth" DLSUFFIX;
 #endif
 
-	state->builtin_flow = dlopen(module_name, RTLD_NOW | RTLD_LOCAL);
-	if (!state->builtin_flow)
+	/*-
+	 * Additionally, the user may override the module path explicitly to be
+	 * able to provide their own module, via PGOAUTHMODULE.
+	 *
+	 * TODO: error messages below need to be rethought when this is set
+	 * TODO: have to think about _all_ the security ramifications of this. What
+	 * existing protections in LD_LIBRARY_PATH (and/or SIP) are we potentially
+	 * bypassing? Should we check the permissions of the file somehow...?
+	 * TODO: maybe disallow anything not underneath LIBDIR? or PKGLIBDIR?
+	 * Should it have a naming convention?
+	 */
+	{
+		const char *env = getenv("PGOAUTHMODULE");
+
+		if (env && env[0])
+			module_name = env;
+	}
+
+	state->flow_module = dlopen(module_name, RTLD_NOW | RTLD_LOCAL);
+	if (!state->flow_module)
 	{
 		/*
 		 * For end users, this probably isn't an error condition, it just
@@ -910,8 +930,7 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *re
 		return 0;
 	}
 
-	if ((init = dlsym(state->builtin_flow, "libpq_oauth_init")) == NULL
-		|| (start_flow = dlsym(state->builtin_flow, "pg_start_oauthbearer")) == NULL)
+	if ((start_flow = dlsym(state->flow_module, "pg_start_oauthbearer")) == NULL)
 	{
 		/*
 		 * This is more of an error condition than the one above, but due to
@@ -920,7 +939,7 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *re
 		if (oauth_unsafe_debugging_enabled())
 			fprintf(stderr, "failed dlsym for libpq-oauth: %s\n", dlerror());
 
-		dlclose(state->builtin_flow);
+		dlclose(state->flow_module);
 		return 0;
 	}
 
@@ -930,34 +949,46 @@ use_builtin_flow(PGconn *conn, fe_oauth_state *state, PGoauthBearerRequestV2 *re
 	 */
 
 	/*
-	 * We need to inject necessary function pointers into the module. This
-	 * only needs to be done once -- even if the pointers are constant,
-	 * assigning them while another thread is executing the flows feels like
-	 * tempting fate.
+	 * Our libpq-oauth.so provides a special initialization function for libpq
+	 * integration. It's not a problem if we don't find this; it just means
+	 * that a user-defined PGOAUTHMODULE is being used.
 	 */
-	if ((lockerr = pthread_mutex_lock(&init_mutex)) != 0)
+	init = dlsym(state->flow_module, "libpq_oauth_init");
+
+	if (!init)
+		state->builtin = false; /* adjust our error messages */
+	else
 	{
-		/* Should not happen... but don't continue if it does. */
-		Assert(false);
+		/*
+		 * We need to inject necessary function pointers into the module. This
+		 * only needs to be done once -- even if the pointers are constant,
+		 * assigning them while another thread is executing the flows feels
+		 * like tempting fate.
+		 */
+		if ((lockerr = pthread_mutex_lock(&init_mutex)) != 0)
+		{
+			/* Should not happen... but don't continue if it does. */
+			Assert(false);
 
-		libpq_append_conn_error(conn, "failed to lock mutex (%d)", lockerr);
-		return 0;
-	}
+			libpq_append_conn_error(conn, "failed to lock mutex (%d)", lockerr);
+			return 0;
+		}
 
-	if (!initialized)
-	{
-		init(pg_g_threadlock,
+		if (!initialized)
+		{
+			init(pg_g_threadlock,
 #ifdef ENABLE_NLS
-			 libpq_gettext
+				 libpq_gettext
 #else
-			 NULL
+				 NULL
 #endif
-			);
+				);
 
-		initialized = true;
-	}
+			initialized = true;
+		}
 
-	pthread_mutex_unlock(&init_mutex);
+		pthread_mutex_unlock(&init_mutex);
+	}
 
 	return (start_flow(conn, request) == 0) ? 1 : -1;
 }
diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build
index a6f937fd7d7..1d898270220 100644
--- a/src/test/modules/oauth_validator/meson.build
+++ b/src/test/modules/oauth_validator/meson.build
@@ -50,6 +50,7 @@ test_install_libs += magic_validator
 
 oauth_hook_client_sources = files(
   'oauth_hook_client.c',
+  'oauth_test_common.c',
 )
 
 if host_system == 'windows'
@@ -67,6 +68,19 @@ oauth_hook_client = executable('oauth_hook_client',
 )
 testprep_targets += oauth_hook_client
 
+oauth_flow = shared_module('oauth_flow',
+  files(
+    'oauth_flow.c',
+    'oauth_test_common.c',
+  ),
+  include_directories: [postgres_inc],
+  dependencies: [frontend_shlib_code, libpq],
+  kwargs: default_lib_args + {
+    'install': false,
+  },
+)
+testprep_targets += oauth_flow
+
 tests += {
   'name': 'oauth_validator',
   'sd': meson.current_source_dir(),
@@ -80,6 +94,7 @@ tests += {
       'PYTHON': python.full_path(),
       'with_libcurl': oauth_flow_supported ? 'yes' : 'no',
       'with_python': 'yes',
+      'flow_module_path': oauth_flow.full_path(),
     },
   },
 }
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
index 05b9f06ed73..e22098dacb8 100644
--- a/src/test/modules/oauth_validator/Makefile
+++ b/src/test/modules/oauth_validator/Makefile
@@ -14,11 +14,13 @@ PGFILEDESC = "validator - test OAuth validator module"
 
 PROGRAM = oauth_hook_client
 PGAPPICON = win32
-OBJS = $(WIN32RES) oauth_hook_client.o
+OBJS = $(WIN32RES) oauth_hook_client.o oauth_test_common.o
 
 PG_CPPFLAGS = -I$(libpq_srcdir)
 PG_LIBS_INTERNAL += $(libpq_pgport)
 
+EXTRA_CLEAN = oauth_flow$(DLSUFFIX) oauth_flow.o
+
 NO_INSTALLCHECK = 1
 
 TAP_TESTS = 1
@@ -33,8 +35,14 @@ top_builddir = ../../../..
 include $(top_builddir)/src/Makefile.global
 include $(top_srcdir)/contrib/contrib-global.mk
 
+all: oauth_flow$(DLSUFFIX)
+
+oauth_flow$(DLSUFFIX): oauth_flow.o oauth_test_common.o
+	$(CC) $(CFLAGS) $^ $(LDFLAGS) $(libpq_pgport_shlib) $(LDFLAGS_SL) -shared -o $@
+
 export PYTHON
 export with_libcurl
 export with_python
+export flow_module_path := $(abs_top_builddir)/$(subdir)/oauth_flow$(DLSUFFIX)
 
 endif
diff --git a/src/test/modules/oauth_validator/oauth_test_common.h b/src/test/modules/oauth_validator/oauth_test_common.h
new file mode 100644
index 00000000000..33e72e30440
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_test_common.h
@@ -0,0 +1,26 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_test_common.h
+ *	  Shared functionality for oauth_hook_client and oauth_flow
+ *
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef OAUTH_TEST_COMMON_H
+#define OAUTH_TEST_COMMON_H
+
+/*
+ * Only public headers can be here, since oauth_flow.c is trying to test only
+ * the public API.
+ */
+#include "libpq-fe.h"
+
+extern int	stress_async;		/* for oauth_hook_client */
+
+extern char *oauth_test_parse_argv(int argc, char *argv[], int for_plugin);
+extern int	oauth_test_authdata_hook(PGauthData type, PGconn *conn, void *data);
+extern int	oauth_test_start_flow(PGconn *conn, PGoauthBearerRequestV2 *request);
+
+#endif							/* OAUTH_TEST_COMMON_H */
diff --git a/src/test/modules/oauth_validator/oauth_flow.c b/src/test/modules/oauth_validator/oauth_flow.c
new file mode 100644
index 00000000000..8068a45ae29
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_flow.c
@@ -0,0 +1,69 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_flow.c
+ *	  Test plugin for clientside OAuth flows
+ *
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include <stdlib.h>
+#include <string.h>
+
+/* Since we want to test the public API, only include public headers here. */
+#include "libpq-fe.h"
+#include "libpq-oauth.h"
+#include "oauth_test_common.h"
+
+static void
+load_test_flags(void)
+{
+	int			argc;
+	char	  **argv;
+	char	   *env = getenv("OAUTH_TEST_FLAGS");
+	int			flag_count;
+	int			i;
+
+	if (!env || !env[0])
+	{
+		fprintf(stderr, "OAUTH_TEST_FLAGS must be set\n");
+		exit(1);
+	}
+
+	flag_count = 1;
+	for (char *c = env; *c; c++)
+	{
+		if (*c == '\x01')
+			flag_count++;
+	}
+
+	argc = flag_count + 1;
+	argv = malloc(sizeof(*argv) * (argc + 1));
+	if (!argv)
+	{
+		fprintf(stderr, "out of memory");
+		exit(1);
+	}
+
+	argv[0] = "[plugin test]";
+	for (i = 1; i < flag_count; i++)
+	{
+		argv[i] = env;
+
+		env = strchr(env, '\x01');
+		*env++ = '\0';
+	}
+	argv[flag_count] = env;
+	argv[argc] = NULL;
+
+	oauth_test_parse_argv(argc, argv, 1 /* plugin */ );
+}
+
+int
+pg_start_oauthbearer(PGconn *conn, PGoauthBearerRequestV2 *request)
+{
+	load_test_flags();
+
+	return oauth_test_start_flow(conn, request);
+}
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
index b1a3b014079..e0b022a268c 100644
--- a/src/test/modules/oauth_validator/oauth_hook_client.c
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -18,144 +18,18 @@
 
 #include <sys/socket.h>
 
-#include "getopt_long.h"
 #include "libpq-fe.h"
-#include "pqexpbuffer.h"
 
-static int	handle_auth_data(PGauthData type, PGconn *conn, void *data);
-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[])
-{
-	printf("usage: %s [flags] CONNINFO\n\n", argv[0]);
-
-	printf("recognized flags:\n");
-	printf("  -h, --help              show this message\n");
-	printf("  -v VERSION              select the hook API version (default 2)\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("  --expected-issuer ISS   fail if received issuer does not match ISS (v2 only)\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\n");
-	printf("  --hang-forever          don't ever return a token (combine with connect_timeout)\n");
-	printf("  --token TOKEN           use the provided TOKEN value\n");
-	printf("  --error ERRMSG          fail instead, with the given ERRMSG (v2 only)\n");
-	printf("  --stress-async          busy-loop on PQconnectPoll rather than polling\n");
-}
-
-/* --options */
-static bool no_hook = false;
-static bool hang_forever = false;
-static bool stress_async = false;
-static const char *expected_uri = NULL;
-static const char *expected_issuer = NULL;
-static const char *expected_scope = NULL;
-static const char *misbehave_mode = NULL;
-static char *token = NULL;
-static char *errmsg = NULL;
-static int	hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN_V2;
+#include "oauth_test_common.h"
 
 int
 main(int argc, char *argv[])
 {
-	static const struct option long_options[] = {
-		{"help", no_argument, NULL, 'h'},
-
-		{"expected-scope", required_argument, NULL, 1000},
-		{"expected-uri", required_argument, NULL, 1001},
-		{"no-hook", no_argument, NULL, 1002},
-		{"token", required_argument, NULL, 1003},
-		{"hang-forever", no_argument, NULL, 1004},
-		{"misbehave", required_argument, NULL, 1005},
-		{"stress-async", no_argument, NULL, 1006},
-		{"expected-issuer", required_argument, NULL, 1007},
-		{"error", required_argument, NULL, 1008},
-		{0}
-	};
-
-	const char *conninfo;
+	const char *conninfo = oauth_test_parse_argv(argc, argv, 0 /* hook */ );
 	PGconn	   *conn;
-	int			c;
-
-	while ((c = getopt_long(argc, argv, "hv:", long_options, NULL)) != -1)
-	{
-		switch (c)
-		{
-			case 'h':
-				usage(argv);
-				return 0;
-
-			case 'v':
-				if (strcmp(optarg, "1") == 0)
-					hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN;
-				else if (strcmp(optarg, "2") == 0)
-					hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN_V2;
-				else
-				{
-					usage(argv);
-					return 1;
-				}
-				break;
-
-			case 1000:			/* --expected-scope */
-				expected_scope = optarg;
-				break;
-
-			case 1001:			/* --expected-uri */
-				expected_uri = optarg;
-				break;
-
-			case 1002:			/* --no-hook */
-				no_hook = true;
-				break;
-
-			case 1003:			/* --token */
-				token = optarg;
-				break;
-
-			case 1004:			/* --hang-forever */
-				hang_forever = true;
-				break;
-
-			case 1005:			/* --misbehave */
-				misbehave_mode = optarg;
-				break;
-
-			case 1006:			/* --stress-async */
-				stress_async = true;
-				break;
-
-			case 1007:			/* --expected-issuer */
-				expected_issuer = optarg;
-				break;
-
-			case 1008:			/* --error */
-				errmsg = optarg;
-				break;
-
-			default:
-				usage(argv);
-				return 1;
-		}
-	}
-
-	if (argc != optind + 1)
-	{
-		usage(argv);
-		return 1;
-	}
-
-	conninfo = argv[optind];
 
 	/* Set up our OAuth hooks. */
-	PQsetAuthDataHook(handle_auth_data);
+	PQsetAuthDataHook(oauth_test_authdata_hook);
 
 	/* Connect. (All the actual work is in the hook.) */
 	if (stress_async)
@@ -193,190 +67,3 @@ main(int argc, char *argv[])
 	PQfinish(conn);
 	return 0;
 }
-
-/*
- * PQauthDataHook implementation. Replaces the default client flow by handling
- * PQAUTHDATA_OAUTH_BEARER_TOKEN[_V2].
- */
-static int
-handle_auth_data(PGauthData type, PGconn *conn, void *data)
-{
-	PGoauthBearerRequest *req;
-	PGoauthBearerRequestV2 *req2 = NULL;
-
-	Assert(hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN ||
-		   hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN_V2);
-
-	if (no_hook || type != hook_version)
-		return 0;
-
-	req = data;
-	if (type == PQAUTHDATA_OAUTH_BEARER_TOKEN_V2)
-		req2 = data;
-
-	if (hang_forever)
-	{
-		/* Start asynchronous processing. */
-		req->async = async_cb;
-		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)
-		{
-			fprintf(stderr, "expected URI \"%s\", got NULL\n", expected_uri);
-			return -1;
-		}
-
-		if (strcmp(expected_uri, req->openid_configuration) != 0)
-		{
-			fprintf(stderr, "expected URI \"%s\", got \"%s\"\n", expected_uri, req->openid_configuration);
-			return -1;
-		}
-	}
-
-	if (expected_scope)
-	{
-		if (!req->scope)
-		{
-			fprintf(stderr, "expected scope \"%s\", got NULL\n", expected_scope);
-			return -1;
-		}
-
-		if (strcmp(expected_scope, req->scope) != 0)
-		{
-			fprintf(stderr, "expected scope \"%s\", got \"%s\"\n", expected_scope, req->scope);
-			return -1;
-		}
-	}
-
-	if (expected_issuer)
-	{
-		if (!req2)
-		{
-			fprintf(stderr, "--expected-issuer cannot be combined with -v1\n");
-			return -1;
-		}
-
-		if (!req2->issuer)
-		{
-			fprintf(stderr, "expected issuer \"%s\", got NULL\n", expected_issuer);
-			return -1;
-		}
-
-		if (strcmp(expected_issuer, req2->issuer) != 0)
-		{
-			fprintf(stderr, "expected issuer \"%s\", got \"%s\"\n", expected_issuer, req2->issuer);
-			return -1;
-		}
-	}
-
-	if (errmsg)
-	{
-		if (token)
-		{
-			fprintf(stderr, "--error cannot be combined with --token\n");
-			return -1;
-		}
-		else if (!req2)
-		{
-			fprintf(stderr, "--error cannot be combined with -v1\n");
-			return -1;
-		}
-
-		appendPQExpBufferStr(req2->error, errmsg);
-		return -1;
-	}
-
-	req->token = token;
-	return 1;
-}
-
-static PostgresPollingStatusType
-async_cb(PGconn *conn, PGoauthBearerRequest *req, pgsocket *altsock)
-{
-	if (hang_forever)
-	{
-		/*
-		 * This code tests that nothing is interfering with libpq's handling
-		 * of connect_timeout.
-		 */
-		static pgsocket sock = PGINVALID_SOCKET;
-
-		if (sock == PGINVALID_SOCKET)
-		{
-			/* First call. Create an unbound socket to wait on. */
-#ifdef WIN32
-			WSADATA		wsaData;
-			int			err;
-
-			err = WSAStartup(MAKEWORD(2, 2), &wsaData);
-			if (err)
-			{
-				perror("WSAStartup failed");
-				return PGRES_POLLING_FAILED;
-			}
-#endif
-			sock = socket(AF_INET, SOCK_DGRAM, 0);
-			if (sock == PGINVALID_SOCKET)
-			{
-				perror("failed to create datagram socket");
-				return PGRES_POLLING_FAILED;
-			}
-		}
-
-		/* Make libpq wait on the (unreadable) socket. */
-		*altsock = sock;
-		return PGRES_POLLING_READING;
-	}
-
-	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". */
-		if (errmsg)
-		{
-			PGoauthBearerRequestV2 *req2;
-
-			if (hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN)
-			{
-				fprintf(stderr, "--error cannot be combined with -v1\n");
-				exit(1);
-			}
-
-			req2 = (PGoauthBearerRequestV2 *) req;
-			appendPQExpBufferStr(req2->error, errmsg);
-		}
-
-		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);
-	}
-}
diff --git a/src/test/modules/oauth_validator/oauth_test_common.c b/src/test/modules/oauth_validator/oauth_test_common.c
new file mode 100644
index 00000000000..f2a5b180a65
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_test_common.c
@@ -0,0 +1,374 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_test_common.c
+ *	  Shared functionality for oauth_hook_client and oauth_flow
+ *
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <sys/socket.h>
+
+#include "getopt_long.h"
+#include "libpq-fe.h"
+#include "pqexpbuffer.h"
+
+#include "oauth_test_common.h"
+
+static PostgresPollingStatusType async_cb(PGconn *conn,
+										  PGoauthBearerRequest *req,
+										  pgsocket *altsock);
+static PostgresPollingStatusType misbehave_cb(PGconn *conn,
+											  PGoauthBearerRequest *req,
+											  pgsocket *altsock);
+
+/* --options */
+static bool no_hook = false;
+static bool hang_forever = false;
+static const char *expected_uri = NULL;
+static const char *expected_issuer = NULL;
+static const char *expected_scope = NULL;
+static const char *misbehave_mode = NULL;
+static char *token = NULL;
+static char *errmsg = NULL;
+static int	hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN_V2;
+
+/*
+ * XXX: stress_async is exported for the benefit of oauth_hook_client. Since
+ * we only use public headers (libpq-fe.h) for oauth_flow, it needs to be an int
+ * rather than a bool.
+ */
+int			stress_async = false;
+
+static void
+usage(char *argv[])
+{
+	printf("usage: %s [flags] CONNINFO\n\n", argv[0]);
+
+	printf("recognized flags:\n");
+	printf("  -h, --help              show this message\n");
+	printf("  -v VERSION              select the hook API version (default 2)\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("  --expected-issuer ISS   fail if received issuer does not match ISS (v2 only)\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\n");
+	printf("  --hang-forever          don't ever return a token (combine with connect_timeout)\n");
+	printf("  --token TOKEN           use the provided TOKEN value\n");
+	printf("  --error ERRMSG          fail instead, with the given ERRMSG (v2 only)\n");
+	printf("  --stress-async          busy-loop on PQconnectPoll rather than polling\n");
+}
+
+char *
+oauth_test_parse_argv(int argc, char *argv[], int for_plugin)
+{
+	static const struct option long_options[] = {
+		{"help", no_argument, NULL, 'h'},
+
+		{"expected-scope", required_argument, NULL, 1000},
+		{"expected-uri", required_argument, NULL, 1001},
+		{"no-hook", no_argument, NULL, 1002},
+		{"token", required_argument, NULL, 1003},
+		{"hang-forever", no_argument, NULL, 1004},
+		{"misbehave", required_argument, NULL, 1005},
+		{"stress-async", no_argument, NULL, 1006},
+		{"expected-issuer", required_argument, NULL, 1007},
+		{"error", required_argument, NULL, 1008},
+		{0}
+	};
+
+	int			c;
+
+	if (for_plugin)
+	{
+		/* The "real" argv has already been parsed. Reset optind. */
+		optind = 1;
+	}
+
+	while ((c = getopt_long(argc, argv, "hv:", long_options, NULL)) != -1)
+	{
+		switch (c)
+		{
+			case 'h':
+				usage(argv);
+				exit(0);
+
+			case 'v':
+				if (strcmp(optarg, "1") == 0)
+					hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN;
+				else if (strcmp(optarg, "2") == 0)
+					hook_version = PQAUTHDATA_OAUTH_BEARER_TOKEN_V2;
+				else
+				{
+					usage(argv);
+					exit(1);
+				}
+				break;
+
+			case 1000:			/* --expected-scope */
+				expected_scope = optarg;
+				break;
+
+			case 1001:			/* --expected-uri */
+				expected_uri = optarg;
+				break;
+
+			case 1002:			/* --no-hook */
+				no_hook = true;
+				break;
+
+			case 1003:			/* --token */
+				token = optarg;
+				break;
+
+			case 1004:			/* --hang-forever */
+				hang_forever = true;
+				break;
+
+			case 1005:			/* --misbehave */
+				misbehave_mode = optarg;
+				break;
+
+			case 1006:			/* --stress-async */
+				stress_async = true;
+				break;
+
+			case 1007:			/* --expected-issuer */
+				expected_issuer = optarg;
+				break;
+
+			case 1008:			/* --error */
+				errmsg = optarg;
+				break;
+
+			default:
+				usage(argv);
+				exit(1);
+		}
+	}
+
+	if (argc != (for_plugin ? optind : optind + 1))
+	{
+		usage(argv);
+		exit(1);
+	}
+
+	return argv[optind];
+}
+
+/*
+ * PQauthDataHook implementation. Replaces the default client flow by handling
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN[_V2].
+ */
+int
+oauth_test_authdata_hook(PGauthData type, PGconn *conn, void *data)
+{
+	PGoauthBearerRequest *req;
+	PGoauthBearerRequestV2 *req2 = NULL;
+
+	Assert(hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN ||
+		   hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN_V2);
+
+	if (no_hook || type != hook_version)
+		return 0;
+
+	req = data;
+	if (type == PQAUTHDATA_OAUTH_BEARER_TOKEN_V2)
+		req2 = data;
+
+	if (hang_forever)
+	{
+		/* Start asynchronous processing. */
+		req->async = async_cb;
+		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)
+		{
+			fprintf(stderr, "expected URI \"%s\", got NULL\n", expected_uri);
+			return -1;
+		}
+
+		if (strcmp(expected_uri, req->openid_configuration) != 0)
+		{
+			fprintf(stderr, "expected URI \"%s\", got \"%s\"\n", expected_uri, req->openid_configuration);
+			return -1;
+		}
+	}
+
+	if (expected_scope)
+	{
+		if (!req->scope)
+		{
+			fprintf(stderr, "expected scope \"%s\", got NULL\n", expected_scope);
+			return -1;
+		}
+
+		if (strcmp(expected_scope, req->scope) != 0)
+		{
+			fprintf(stderr, "expected scope \"%s\", got \"%s\"\n", expected_scope, req->scope);
+			return -1;
+		}
+	}
+
+	if (expected_issuer)
+	{
+		if (!req2)
+		{
+			fprintf(stderr, "--expected-issuer cannot be combined with -v1\n");
+			return -1;
+		}
+
+		if (!req2->issuer)
+		{
+			fprintf(stderr, "expected issuer \"%s\", got NULL\n", expected_issuer);
+			return -1;
+		}
+
+		if (strcmp(expected_issuer, req2->issuer) != 0)
+		{
+			fprintf(stderr, "expected issuer \"%s\", got \"%s\"\n", expected_issuer, req2->issuer);
+			return -1;
+		}
+	}
+
+	if (errmsg)
+	{
+		if (token)
+		{
+			fprintf(stderr, "--error cannot be combined with --token\n");
+			return -1;
+		}
+		else if (!req2)
+		{
+			fprintf(stderr, "--error cannot be combined with -v1\n");
+			return -1;
+		}
+
+		appendPQExpBufferStr(req2->error, errmsg);
+		return -1;
+	}
+
+	req->token = token;
+	return 1;
+}
+
+/*
+ * Sets up a request for a plugin module (pg_start_oauthbearer()) rather than
+ * using the hook.
+ */
+int
+oauth_test_start_flow(PGconn *conn, PGoauthBearerRequestV2 *request)
+{
+	int			ret;
+
+	/*
+	 * We can still defer to the hook above to avoid copying code; we just
+	 * have to translate the return value.
+	 */
+	ret = oauth_test_authdata_hook(PQAUTHDATA_OAUTH_BEARER_TOKEN_V2, conn,
+								   request);
+
+	if (ret == 0)
+	{
+		/* This is a bug in the test. */
+		fprintf(stderr, "plugin tests cannot make use of -v1 or --no-hook\n");
+		exit(1);
+	}
+
+	return (ret == 1) ? 0 : -1;
+}
+
+static PostgresPollingStatusType
+async_cb(PGconn *conn, PGoauthBearerRequest *req, pgsocket *altsock)
+{
+	if (hang_forever)
+	{
+		/*
+		 * This code tests that nothing is interfering with libpq's handling
+		 * of connect_timeout.
+		 */
+		static pgsocket sock = PGINVALID_SOCKET;
+
+		if (sock == PGINVALID_SOCKET)
+		{
+			/* First call. Create an unbound socket to wait on. */
+#ifdef WIN32
+			WSADATA		wsaData;
+			int			err;
+
+			err = WSAStartup(MAKEWORD(2, 2), &wsaData);
+			if (err)
+			{
+				perror("WSAStartup failed");
+				return PGRES_POLLING_FAILED;
+			}
+#endif
+			sock = socket(AF_INET, SOCK_DGRAM, 0);
+			if (sock == PGINVALID_SOCKET)
+			{
+				perror("failed to create datagram socket");
+				return PGRES_POLLING_FAILED;
+			}
+		}
+
+		/* Make libpq wait on the (unreadable) socket. */
+		*altsock = sock;
+		return PGRES_POLLING_READING;
+	}
+
+	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". */
+		if (errmsg)
+		{
+			PGoauthBearerRequestV2 *req2;
+
+			if (hook_version == PQAUTHDATA_OAUTH_BEARER_TOKEN)
+			{
+				fprintf(stderr, "--error cannot be combined with -v1\n");
+				exit(1);
+			}
+
+			req2 = (PGoauthBearerRequestV2 *) req;
+			appendPQExpBufferStr(req2->error, errmsg);
+		}
+
+		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);
+	}
+}
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
index f878a23d201..b23e579f1fd 100644
--- a/src/test/modules/oauth_validator/t/002_client.pl
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -1,6 +1,6 @@
 #
 # Exercises the API for custom OAuth client flows, using the oauth_hook_client
-# test driver.
+# test driver and the oauth_flow custom plugin.
 #
 # Copyright (c) 2021-2025, PostgreSQL Global Development Group
 #
@@ -20,6 +20,10 @@ if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
 	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
 }
 
+my $plugin_supported = (
+		 check_pg_config("#define HAVE_SYS_EVENT_H 1")
+	  or check_pg_config("#define HAVE_SYS_EPOLL_H 1"));
+
 #
 # Cluster Setup
 #
@@ -72,6 +76,8 @@ sub test
 		$flags = $params{flags};
 	}
 
+	# First run the oauth_hook_client, which uses PQauthDataHook to insert a new
+	# OAuth flow.
 	my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr);
 	note "running '" . join("' '", @cmd) . "'";
 
@@ -103,6 +109,37 @@ sub test
 		$node->log_check("$test_name: log matches",
 			$log_start, log_like => $params{log_like});
 	}
+
+  SKIP:
+	{
+		last SKIP if $params{hook_only};
+		skip "OAuth modules are not supported on this platform"
+		  unless $plugin_supported;
+
+		# Run the same test with psql itself, loading the oauth_flow.so module.
+		local $ENV{PGOAUTHMODULE} = $ENV{flow_module_path};
+
+		# Flags are passed to the module via OAUTH_TEST_FLAGS, with 0x01 as a
+		# separator.
+		local $ENV{OAUTH_TEST_FLAGS} = join("\x01", @{$flags});
+
+		if ($params{expect_success})
+		{
+			$node->connect_ok(
+				$common_connstr,
+				"[plugin flow] $test_name",
+				expected_stderr => $params{expected_stderr},
+				log_like => $params{log_like});
+		}
+		else
+		{
+			$node->connect_fails(
+				$common_connstr,
+				"[plugin flow] $test_name",
+				expected_stderr => $params{expected_stderr},
+				log_like => $params{log_like});
+		}
+	}
 }
 
 test(
@@ -119,6 +156,7 @@ test(
 # Make sure the v1 hook continues to work.
 test(
 	"v1 synchronous hook can provide a token",
+	hook_only => 1,    # plugins don't support API v1
 	flags => [
 		"-v1",
 		"--token" => "my-token-v1",
@@ -133,6 +171,7 @@ if ($ENV{with_libcurl} ne 'yes')
 	# libpq should help users out if no OAuth support is built in.
 	test(
 		"fails without custom hook installed",
+		hook_only => 1,    # plugins can't use --no-hook
 		flags => ["--no-hook"],
 		expected_stderr =>
 		  qr/no OAuth flows are available \(try installing the libpq-oauth package\)/
-- 
2.34.1